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 (2)
Validation belongs at the edge, but the edge isn't always the route handler. Put it in the service and you get the same checks twice, once in the Pydantic model and once in a domain rule that can't see the request. I've kept the schema doing shape and the service doing rules, and neither one pretends to own the other.
The duplication you mention is the trap I try to avoid. If a check is purely structural (types, required fields, lengths), it lives in the schema. If it needs context like the database, current state, or permissions, it lives in the service. If I find the same check in both places, one of them is usually in the wrong layer.