March 18, 2026
The MSCR Pattern: Structuring Every FastAPI Backend
Fat route handlers are a trap. Here's the four-layer architecture that keeps backends testable, maintainable, and sane.
Every framework tutorial starts the same way: a route function that does everything. It validates the request, queries the database, applies business rules, and returns a response, all in one place. It's quick to write, easy to understand at first, and becomes a nightmare to maintain.
The MSCR pattern (Models, Services, Controllers (routers), Repositories) is a way to structure every FastAPI backend to avoid that trap. Each layer has one job, and nothing else.
The Problem with Fat Route Handlers
Here's what a "just works" route looks like:
@router.post("/users/{user_id}/posts")
async def create_post(user_id: int, data: dict, db: AsyncSession = Depends(get_db)):
user = await db.get(User, user_id)
if not user:
raise HTTPException(404, "User not found")
existing = await db.execute(
select(Post).where(Post.title == data["title"], Post.user_id == user_id)
)
if existing.scalar_one_or_none():
raise HTTPException(409, "Post already exists")
post = Post(title=data["title"], body=data["body"], user_id=user_id)
db.add(post)
await db.commit()
return {"id": post.id, "title": post.title}This function is doing four things at once: parsing HTTP input, enforcing a business rule (no duplicate titles), persisting data, and formatting a response. Writing a unit test for the "no duplicate titles" rule means also testing FastAPI routing and the database session. Changing the query means touching the same file as the HTTP logic.
The MSCR pattern separates all of this.
The Four Layers
Routers (HTTP) → Services (Business Logic) → Repositories (Data Access) → Models (ORM)
Each layer only talks to the one below it. Nothing skips a layer.
Models
The bottom of the stack. Pure SQLAlchemy ORM classes, with no business logic and no HTTP awareness. Just columns, relationships, and mixins.
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy import String, Text, ForeignKey
from app.models.base import Base, TimestampMixin, PublicIdMixin
class Post(Base, TimestampMixin, PublicIdMixin):
__tablename__ = "posts"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
title: Mapped[str] = mapped_column(String(200), nullable=False)
body: Mapped[str] = mapped_column(Text, nullable=False)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False)The model knows nothing about how it's queried or what rules govern its creation. That's not its job.
Repositories
The data access layer. Repositories own all SQL, and they're the only place the database session is used directly. A generic BaseRepository handles common CRUD, and specific repositories add custom queries.
from app.repositories.base import BaseRepository
from app.models.post import Post
class PostRepository(BaseRepository[Post]):
def __init__(self, session: AsyncSession):
super().__init__(Post, session)
async def find_by_title_and_user(self, title: str, user_id: int) -> Post | None:
result = await self.session.execute(
select(Post).where(
Post.title == title,
Post.user_id == user_id,
Post.is_deleted.is_(False),
)
)
return result.scalar_one_or_none()One critical rule: repositories never call commit(). They only flush(), which stages changes and assigns IDs without writing to the database permanently. The service layer decides when to commit.
Services
The business logic layer. Services orchestrate repositories, enforce rules, and own transaction boundaries. They know nothing about HTTP: no request objects, no status codes.
from sqlalchemy.exc import IntegrityError
class PostService:
def __init__(self, repo: PostRepository):
self.repo = repo
async def create_post(self, user_id: int, data: PostCreate) -> Post:
if await self.repo.find_by_title_and_user(data.title, user_id):
raise ConflictError("A post with this title already exists")
try:
post = await self.repo.create(
title=data.title,
body=data.body,
user_id=user_id,
)
await self.repo.commit()
return post
except IntegrityError:
await self.repo.rollback()
raise ConflictError("A post with this title already exists")Notice the service both checks for duplicates and catches IntegrityError. That's intentional. See the separate post on race conditions for why.
Routers (Controllers)
The HTTP layer. Routers parse requests, call services, and format responses. No business logic, no direct database access.
from fastapi import APIRouter, Depends
router = APIRouter(prefix="/api/v1/posts", tags=["Posts"])
@router.post("/users/{user_public_id}/posts", status_code=201)
async def create_post(
user_public_id: str,
data: PostCreate,
service: PostService = Depends(get_post_service),
) -> PostResponse:
post = await service.create_post(user_public_id=user_public_id, data=data)
return PostResponse.model_validate(post)The router doesn't know how the duplicate check works or how the database is queried. It just calls the service and formats the result.
How the Layers Work Together
When a POST /api/v1/posts/users/abc-123/posts request comes in:
- Router parses and validates the JSON body via Pydantic, calls the service
- Service checks for duplicates, creates the record via the repository, commits
- Repository runs the SQL, flushes to get the assigned ID
- Model defines the schema: columns, types, relationships
- Response bubbles back up: repository returns the model, service returns it, router serializes it
Why This Is Worth the Extra Files
The real payoff shows up when the codebase grows.
Testability. The service layer can be tested with a mocked repository, no database needed. The repository can be tested with a real database but no HTTP setup. The router can be tested with a mocked service and no business logic. Each layer tests independently.
Replaceability. Want to switch from SQLite to PostgreSQL? Only the repository layer cares. Want to change the duplicate-title rule? Only the service changes. Want to add a new interface alongside REST? Write a new router that calls the same services.
Readability. When something breaks, it's obvious which layer to look in. A 409 Conflict error? Service layer. A SQL performance issue? Repository layer. A 422 Unprocessable Entity? Router or schema.
The initial cost of splitting code across four files pays off quickly. The alternative, one fat route handler that does everything, only feels simpler until the third time someone is debugging a business rule buried inside a route function.