Every FastAPI tutorial starts the same way: one file, a few @app.get decorators, a Pydantic model, done. That's great for a demo. It falls apart the moment you have more than one entity, more than one person touching the code, or a bug report that starts with "it validated fine but broke downstream."
I've hit that wall enough times (see my last post, ValidationError Is Not a Policy, on why a passing ValidationError check isn't a policy) that I've settled on a structure I now reach for by default. Nothing exotic — no hexagonal-architecture diagrams, no ports-and-adapters ceremony. Just four layers with one job each, and a DI setup that keeps them from knowing too much about one another.
The four layers
app/
├── api/ # routers — HTTP in, HTTP out, nothing else
├── services/ # business logic — no HTTP, no SQL
├── repositories/ # persistence — no business logic
├── schemas/ # Pydantic models — shape, not meaning
The rule I enforce on myself: each layer is only allowed to talk to the one directly below it. A router never touches a repository. A service never sees a Request object. If I catch myself importing fastapi inside services/, that's a signal the logic is in the wrong place.
api/ — routers
The router's job is translation: turn an HTTP request into a function call, turn a return value into an HTTP response. That's it.
@router.post("/orders", response_model=OrderOut)
async def create_order(
payload: OrderCreate,
service: OrderService = Depends(get_order_service),
):
order = await service.create(payload)
return order
No try/except here beyond translating known service exceptions into HTTP status codes. If a router has an if statement that isn't about status codes, it's doing the service's job.
services/ — business logic
This is where the actual rules live: what makes an order valid, what happens when stock runs out, which side effects fire in which order. Services depend on repository interfaces, not concrete database calls — which is what makes them testable without spinning up Postgres.
class OrderService:
def __init__(self, orders: OrderRepository, inventory: InventoryRepository):
self.orders = orders
self.inventory = inventory
async def create(self, data: OrderCreate) -> Order:
if not await self.inventory.has_stock(data.sku, data.quantity):
raise InsufficientStockError(data.sku)
return await self.orders.save(Order.from_create(data))
repositories/ — persistence
Repositories know SQL (or whatever storage you use). They don't know why a row is being saved, only how. This is the layer I swap out in tests for an in-memory fake.
schemas/ — Pydantic models
Here's the distinction that took me longest to internalize: schemas describe shape, services describe meaning. A Pydantic model can tell you a field is a positive integer. It cannot tell you that quantity can't exceed available stock, or that a discount code has expired. Those are business rules, and they belong in the service layer — not stuffed into a @validator.
I made this mistake for a while: piling business logic into Pydantic validators because it felt convenient. It works right up until the rule needs to check something outside the payload — a database lookup, another field's async state, today's date against a promo window. Then the validator either can't do it, or does it badly with hidden side effects. Split it: Pydantic checks is this shape well-formed, the service checks is this operation allowed.
Dependency injection: keep it boring
FastAPI's Depends() is enough. I don't reach for a DI container unless the service genuinely has cross-cutting concerns (multi-tenant config, feature flags per request). Most of the time this is all it takes:
def get_order_repository(db: AsyncSession = Depends(get_db)) -> OrderRepository:
return OrderRepository(db)
def get_order_service(
orders: OrderRepository = Depends(get_order_repository),
inventory: InventoryRepository = Depends(get_inventory_repository),
) -> OrderService:
return OrderService(orders, inventory)
The payoff shows up in tests, not in the app itself:
def test_create_order_raises_when_out_of_stock():
service = OrderService(
orders=FakeOrderRepository(),
inventory=FakeInventoryRepository(stock={"SKU1": 0}),
)
with pytest.raises(InsufficientStockError):
await service.create(OrderCreate(sku="SKU1", quantity=1))
No TestClient, no database, no mocking fastapi.Depends. Just a plain object with fake dependencies.
Where this breaks down
This structure earns its keep once you have real business rules and more than a couple of endpoints. For a small internal tool or a weekend project, it's overkill — a single main.py is the right call, and I'd tell anyone insisting on four layers for a CRUD toy to stop. The cost is real: more files, more indirection, one extra hop to trace a request end to end.
The tell that it's time to split things up isn't line count, it's this: the moment a validator needs to ask "but is this actually allowed right now" instead of "is this shaped correctly," you've outgrown a single file, whether you've noticed it yet or not.
I write about things that break while I build Boolflow and RealFeedApp. If you've got a different way of drawing these lines, I'd like to hear where it diverges.
Top comments (0)