Practical FastAPI × Clean Architecture Guide: Growing a Maintainable API with Router Splitting, a Service Layer, and the Repository Pattern
Introduction: The Goal of This Article
Getting to the point where you can build a small API with FastAPI is usually pretty quick.
But as users and features grow, you may gradually start running into problems like:
- You’re not sure where a piece of logic should live
- One file keeps getting bigger and bigger
- Every spec change forces you to touch lots of places, and it feels risky
This article organizes clean-architecture-style design patterns—with concrete directory layouts and code examples—to help you evolve a FastAPI app into something that’s maintainable over the mid to long term.
Who Benefits from Reading This? (Concrete personas)
-
Solo developers / learners
Your small FastAPI app has grown, and you’re thinking “main.pyis bursting…” or “myroutersare chaos…”
→ You’ll get a clear path for organizing code using layers, a service layer, and the repository pattern without overdoing it. -
Backend engineers on small teams
A 3–5 person team is building a FastAPI-based Web API, and as features grow, styles are starting to diverge.
→ You’ll take home a realistic architecture baseline—shared patterns without heavy-handed rules. -
Startup teams building a SaaS
You want to move from “it works” to “we’re not afraid of spec changes.”
→ You’ll learn how to organize responsibilities by domain and migrate toward a testable, decomposed structure.
Accessibility Notes (Readability considerations)
- Each section uses headings and is kept to ~1–3 paragraphs, inserting code only where needed.
- Terms like “clean architecture,” “layered architecture,” “repository,” and “use case” are explained in plain language when first introduced.
- Code is shown in monospaced blocks, with minimal comments to keep scanning easy.
- Assumes you’ve used Python and FastAPI to some degree, but each section is designed to be readable independently.
Overall, as a technical article, this aims for readability and clarity roughly in line with WCAG AA.
1. Why Architecture Matters: The Limit of “Fat Routers”
Let’s briefly clarify why we bother with layers at all.
At first, code like this works just fine:
# The “everything in app/main.py” pattern (a common beginning)
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
from sqlalchemy import create_engine, text
app = FastAPI()
engine = create_engine("sqlite:///./app.db", echo=True)
class Article(BaseModel):
id: int | None = None
title: str
body: str
@app.post("/articles", response_model=Article)
def create_article(article: Article):
with engine.begin() as conn:
res = conn.execute(
text("INSERT INTO articles (title, body) VALUES (:t, :b) RETURNING id"),
{"t": article.title, "b": article.body},
)
article.id = res.scalar_one()
return article
But over time, this endpoint starts mixing in:
- Authentication / authorization (roles, permissions)
- Branching validation rules
- Business rules (draft → published state transitions)
- DB access and external API calls
- Logging, transaction control, tracing, etc.
Before you know it, one endpoint is ~100 lines, and you’re thinking “where do I even fix this?”
The purpose of architecture—very roughly—is just these three things:
- Reduce the blast radius of change
- Separate code by responsibility so your brain can switch contexts
- Make tests easier (especially for business logic)
2. The Basic Layer Model: What “Layers” Do We Split Into?
Clean architecture / layered architecture has many variations, but a realistic FastAPI-friendly mental model is:
- Presentation layer (API routers)
- Application layer (services / use cases)
- Domain layer (domain models / business rules)
- Infrastructure layer (DB, external APIs, messaging)
A sample directory structure:
app/
api/
v1/
routers/
articles.py # Router (endpoint definitions)
core/
settings.py # Settings / shared infrastructure
logging.py
domain/
articles/
models.py # Domain model
services.py # Use cases (service layer)
repositories.py # Repository interface + contract
schemas.py # Pydantic schemas (API)
infra/
db/
base.py # Base, SessionLocal
articles/
sqlalchemy_repo.py # SQLAlchemy repository implementation
main.py
The key ideas:
- Routers contain only HTTP-facing logic (auth info extraction, request/response translation)
- Business logic lives in the service layer
- DB access is isolated in repositories
You don’t need to split perfectly on day one—migrate gradually.
3. Narrow the Router’s Responsibility: Aim for Thin Endpoints
From the router’s perspective, an endpoint ideally stays within this flow:
- Receive request params/body
- Extract auth user / tenant info
- Call a service layer method
- Return the result in a response schema
Here’s a cleaned-up “create article” example:
# app/api/v1/routers/articles.py
from fastapi import APIRouter, Depends, status
from app.domain.articles.schemas import ArticleCreate, ArticleRead
from app.domain.articles.services import ArticleService
from app.deps.services import get_article_service
from app.deps.auth import get_current_user
router = APIRouter(prefix="/articles", tags=["articles"])
@router.post(
"",
response_model=ArticleRead,
status_code=status.HTTP_201_CREATED,
)
def create_article(
payload: ArticleCreate,
service: ArticleService = Depends(get_article_service),
current_user=Depends(get_current_user),
):
# Keep only HTTP-adjacent logic here
article = service.create_article(
author_id=current_user.id,
data=payload,
)
return article
Here:
ArticleCreate/ArticleRead: API-facing Pydantic schemasArticleService: service layer (business logic)get_current_user: dependency that returns the authenticated user
The router becomes a “receive & pass along” layer.
4. Defining the Service Layer (Use Cases)
Next, the service layer that holds business logic.
We’ll use an ArticleService that groups create/update/publish, etc.
# app/domain/articles/services.py
from dataclasses import dataclass
from typing import Protocol
from app.domain.articles.schemas import ArticleCreate, ArticleRead
from app.domain.articles.models import Article
class ArticleRepository(Protocol):
def add(self, article: Article) -> Article: ...
def get(self, article_id: int) -> Article | None: ...
# Add find_by_author, etc. as needed
@dataclass
class ArticleService:
repo: ArticleRepository
def create_article(self, author_id: int, data: ArticleCreate) -> ArticleRead:
article = Article(
title=data.title,
body=data.body,
author_id=author_id,
status="draft", # Initial state is draft
)
saved = self.repo.add(article)
return ArticleRead.model_validate(saved)
This does:
- Create the domain model (
Article) - Apply business rules (here:
status="draft") - Ask the repository to persist it
- Convert to a response schema
Why service layers are nice:
- Not tied to HTTP (can be reused outside FastAPI)
- Easy to test (mock only the repository)
- Spec changes have a clear “home” (read the service layer for the rules)
5. Split Domain Models and Pydantic Schemas
Having a “domain model” at the center makes business rules easier to reason about.
For simplicity, this example treats the SQLAlchemy model as the domain model.
# app/domain/articles/models.py
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
from sqlalchemy import Integer, String, Text, ForeignKey
class Base(DeclarativeBase):
pass
class Article(Base):
__tablename__ = "articles"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
title: Mapped[str] = mapped_column(String(200), nullable=False)
body: Mapped[str] = mapped_column(Text, nullable=False)
author_id: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False)
status: Mapped[str] = mapped_column(String(20), default="draft", nullable=False)
Keep API-facing Pydantic schemas separate:
# app/domain/articles/schemas.py
from pydantic import BaseModel
class ArticleCreate(BaseModel):
title: str
body: str
class ArticleRead(BaseModel):
id: int
title: str
body: str
author_id: int
status: str
class Config:
from_attributes = True
This separation helps decouple:
- External “HTTP world” types (
ArticleCreate,ArticleRead) - Internal “DB/domain world” types (
Article)
So you can evolve implementation without constantly rewriting the API contract.
6. Isolate DB Access with the Repository Pattern
Now, the infrastructure repository layer.
From the service layer’s viewpoint, it only needs “something that saves articles,” so we define an interface (Protocol) first.
# app/domain/articles/repositories.py
from typing import Protocol
from app.domain.articles.models import Article
class ArticleRepository(Protocol):
def add(self, article: Article) -> Article: ...
def get(self, article_id: int) -> Article | None: ...
Then the real SQLAlchemy implementation lives in the infra layer:
# app/infra/articles/sqlalchemy_repo.py
from sqlalchemy.orm import Session
from app.domain.articles.models import Article
from app.domain.articles.repositories import ArticleRepository
class SqlAlchemyArticleRepository(ArticleRepository):
def __init__(self, db: Session):
self.db = db
def add(self, article: Article) -> Article:
self.db.add(article)
self.db.flush() # Assign an id
self.db.refresh(article)
return article
def get(self, article_id: int) -> Article | None:
return self.db.get(Article, article_id)
Benefits:
- Service layer depends only on the abstraction (
ArticleRepository) - Switching DBs later becomes mostly “swap the infra implementation”
- You can also implement a fake/in-memory version easily
7. Connect the Layers with FastAPI Dependency Injection
After splitting router/service/repository, the question becomes “how do we wire them up?”
That’s where FastAPI’s Depends shines.
7.1 DB session dependency
# app/infra/db/base.py
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
engine = create_engine("sqlite:///./app.db", connect_args={"check_same_thread": False})
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False)
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
7.2 Build repository + service dependencies
# app/deps/services.py
from fastapi import Depends
from sqlalchemy.orm import Session
from app.infra.db.base import get_db
from app.infra.articles.sqlalchemy_repo import SqlAlchemyArticleRepository
from app.domain.articles.services import ArticleService
def get_article_repository(db: Session = Depends(get_db)):
return SqlAlchemyArticleRepository(db)
def get_article_service(repo=Depends(get_article_repository)) -> ArticleService:
return ArticleService(repo=repo)
Now the router just Depends(get_article_service).
# app/api/v1/routers/articles.py (recap)
@router.post("", response_model=ArticleRead)
def create_article(
payload: ArticleCreate,
service: ArticleService = Depends(get_article_service),
current_user=Depends(get_current_user),
):
return service.create_article(author_id=current_user.id, data=payload)
This keeps layers split while using FastAPI’s injection pipeline as the glue.
8. Transactions and the Unit of Work Pattern (Briefly)
As business logic spans multiple repositories, you run into:
“Where do we start/commit the transaction?”
A common approach:
- Treat each service method (use case) as one transaction unit
- Use a Unit of Work object to bundle: begin → multiple repo operations → commit/rollback
A simplified example:
# app/infra/db/unit_of_work.py
from contextlib import AbstractContextManager
from dataclasses import dataclass
from sqlalchemy.orm import Session
from app.infra.db.base import SessionLocal
@dataclass
class UnitOfWork(AbstractContextManager):
session: Session | None = None
def __enter__(self):
self.session = SessionLocal()
return self
def __exit__(self, exc_type, exc, tb):
try:
if exc_type is None:
self.session.commit()
else:
self.session.rollback()
finally:
self.session.close()
In the service layer, it can look like:
from app.infra.db.unit_of_work import UnitOfWork
from app.infra.articles.sqlalchemy_repo import SqlAlchemyArticleRepository
def create_article_with_uow(author_id: int, data: ArticleCreate) -> ArticleRead:
with UnitOfWork() as uow:
repo = SqlAlchemyArticleRepository(uow.session)
article = Article(
title=data.title,
body=data.body,
author_id=author_id,
status="draft",
)
saved = repo.add(article)
return ArticleRead.model_validate(saved)
In real projects, you’ll often inject the UoW and ensure multiple repositories share the same session.
9. Make Testing Easier: Test Only the Service Layer
Layering pays off most visibly in tests.
9.1 Unit test the service layer
Because ArticleService depends only on a repository interface, we can pass a simple in-memory fake for tests:
# tests/test_article_service.py
from app.domain.articles.services import ArticleService
from app.domain.articles.schemas import ArticleCreate
from app.domain.articles.models import Article
class InMemoryArticleRepo:
def __init__(self):
self.items: list[Article] = []
self._next_id = 1
def add(self, article: Article) -> Article:
article.id = self._next_id
self._next_id += 1
self.items.append(article)
return article
def get(self, article_id: int) -> Article | None:
for a in self.items:
if a.id == article_id:
return a
return None
def test_create_article_sets_draft_status():
repo = InMemoryArticleRepo()
service = ArticleService(repo=repo)
data = ArticleCreate(title="Hello", body="World")
result = service.create_article(author_id=1, data=data)
assert result.status == "draft"
assert result.title == "Hello"
assert result.author_id == 1
No HTTP, no DB—just business rules.
These tests become excellent regression protection when specs change.
10. Split by Feature (Domain) So You Don’t Get Lost as You Scale
Directory layouts vary, but in FastAPI, “group by feature/domain” often works well.
Like:
domain/
articles/
models.py
services.py
repositories.py
schemas.py
users/
models.py
services.py
...
Benefits:
- Related files stay close, improving readability
- Easier to split responsibilities in a team (“articles owner,” “users owner,” etc.)
- Keeps the option open to extract a feature into a separate service later
If you split directories strictly by layers (controller/service/repository), you often end up jumping across folders to understand a single feature.
There’s no absolute right answer, but “by feature” tends to fit web APIs well.
11. Common Anti-Patterns and Gentle Escape Routes
Reality includes a lot of “classic” pain points:
| Pattern | Why it hurts | How to escape |
|---|---|---|
Everything in main.py |
One huge file is hard to read | First, split routers into api/routers |
| Routers become too fat | HTTP and business logic mix | Create one service module and move logic gradually |
| DB access is written everywhere | Connection/transactions become inconsistent | Introduce repository classes and route DB access through them (start with one feature) |
| Tests are too hard so they’re postponed | Every change needs manual checking | Extract the service layer and add unit tests first |
The key is: don’t try to “clean everything” in one shot.
Refactor one endpoint into service + repository, then repeat. Small steps are kinder to both you and the codebase.
12. What This Architecture Changes (By Reader Persona)
-
Solo devs / learners
- Avoid the “debt pile-up until it’s unchangeable” outcome as features grow.
- You can also talk concretely in interviews/projects: “I can structure a FastAPI app like this,” which becomes a portfolio advantage.
-
Small teams
- Shared understanding of “where things go” makes reviews and collaboration far easier.
- Onboarding becomes simpler: “Check
domain/xxxand you’ll understand most of it.”
-
SaaS teams
- Keeping business rules in the service layer gives confidence: “all rules live here.”
- You can more easily adapt to architectural shifts (e.g., extracting one feature into a separate service).
13. An Adoption Roadmap: How to Improve Gradually
A staged path to a clean-architecture-like FastAPI structure:
-
Split routers
- Move route definitions out of
main.pyintoapi/v1/routers/*.py. - No behavior change—just better visibility.
- Move route definitions out of
-
Create one service module
- Pick a representative domain (e.g., articles), create
domain/articles/services.py, and move logic for a single endpoint.
- Pick a representative domain (e.g., articles), create
-
Separate Pydantic schemas from domain models
- Put
ArticleCreate/ArticleReadinschemas.py, and have routers focus mostly on schemas.
- Put
-
Introduce the repository pattern
- Centralize DB access in something like
SqlAlchemyArticleRepository, and have the service depend only on the repository abstraction.
- Centralize DB access in something like
-
Start writing tests
- Begin with service-layer tests using mocked repositories, then grow coverage by use case.
-
Expand across domains
- Apply the same pattern to users/comments/tags, etc.
- Add UoW, events, and other advanced patterns only when needed.
Trying to do everything at once becomes overwhelming—grow it one feature, one endpoint at a time.
Reference Links (For deeper study)
These are general resources on clean architecture and FastAPI design. Please check each source for the latest information.
-
Clean architecture / design in general
- Clean Architecture: A Craftsman’s Guide to Software Structure and Design
- Domain-Driven Design (intro-level books / guides)
-
Python / FastAPI + clean architecture
-
Repository pattern / use cases
- Eric Evans, Domain-Driven Design
- Martin Fowler, Patterns of Enterprise Application Architecture
-
Testing and architecture
- FastAPI Testing
- pytest official documentation
Closing Thoughts
FastAPI makes it wonderfully easy to build “something that works.”
At the same time, main.py and routers can quietly balloon until touching them feels scary.
The patterns covered here—layering, a service layer, and the repository pattern—are simply one workable “shape” to:
- Make business logic easier to find
- Reduce the blast radius of change
- Make testing easier
There’s no single absolute correct answer. But if you start with this shape and adapt it gradually to fit your team and project, your FastAPI app is far more likely to become something you can live with for the long haul.
At your own pace, try starting with just one endpoint.
Little by little, you’ll end up with an API that’s cleaner, safer to change, and more enjoyable to build.
