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 (2)

Collapse
 
nark3d profile image
Adam Lewis •

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.

Collapse
 
den0011 profile image
Den •

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.