DEV Community

Cover image for How I Structure a FastAPI Service: Layers, DI, and Where Validation Actually Belongs
Den
Den

Posted on

How I Structure a FastAPI Service: Layers, DI, and Where Validation Actually Belongs

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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))
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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))
Enter fullscreen mode Exit fullscreen mode

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)