Back to articles

Clean architecture in FastAPI without overengineering

Where I draw the layer boundaries in a real FastAPI service, and where I deliberately stop drawing them.

4.7(63)Your rating

Clean architecture discussions tend to jump straight to diagrams with five concentric circles. Most FastAPI services need exactly three layers, and the discipline is not in adding layers but in respecting the two boundaries between them.

The three layers

routers/     HTTP: request parsing, status codes, auth dependencies
services/    business logic: pure-ish Python, no HTTP, no ORM queries
repositories/ persistence: all SQLAlchemy lives here

The rules that matter:

  • Routers never touch the ORM. A router parses input, calls one service function, and shapes the response. If a router has a select() in it, the boundary is already gone.
  • Services never import FastAPI. No Request, no HTTPException. Services raise domain errors (InsufficientFunds, LoanNotFound) and an exception handler maps them to status codes in one place.
  • Repositories return domain objects, not rows. The service layer should not know whether data came from Postgres, a cache, or a test fake.

What I deliberately skip

No use-case classes with one method. A module of plain functions does the same job with less ceremony. When a function needs shared dependencies, an explicit parameter beats a class holding it.

No repository interfaces "for swappability". We are not going to swap Postgres. Tests use the real database in a container; fakes appear only where the real thing is slow or external (payment gateways, LLM calls).

No DTO-to-entity-to-model mapping chains. Pydantic schemas at the edge, domain dataclasses inside, and one honest mapping function between them. Three representations is already plenty.

The payoff, concretely

On a lending platform this structure meant the underwriting rules lived in services/underwriting.py as plain functions over dataclasses. When the business asked "what would change if we lowered the minimum score", we answered by running the service layer against a CSV of historical applications. No HTTP, no database, just imports. That test took minutes to write because the boundary was already there.

Overengineering and underengineering fail the same way: the code stops telling you where things belong. Three layers and two enforced boundaries is the smallest structure I know that keeps the answer obvious.

Was this article helpful?

Tap a star to rate

Keep reading