Text size
Dark mode
RecallRunGuides & benchmarks
Learn to build real systemsGuides•Benchmarks•Experiments•Community
Search the site/Free checklist

Guides/FastAPI

FastAPI: Routers, layers & project structure

Tutorials put everything in main.py . Real services outgrow that within weeks.

In this post

What this post covers
  1. 1. The layers
  2. 2. Build it for real
  3. 3. Router features worth knowing
  4. 4. Organise by layer or by feature?
  5. 5. Why an app factory
  6. Recap

Tutorials put everything in main.py. Real services outgrow that within weeks. The structure that lasts has three ideas: routers group endpoints by resource, an app factory builds the application (so tests can build their own), and layers keep HTTP, business rules and persistence apart, so each can change and be tested on its own. This part builds that structure as real files and runs it.

🏪

Front of house, kitchen, pantry

Waiters (routers) talk to customers and take orders but never cook. The kitchen (services) knows the recipes and rules, and does not care whether the order came from a table or a delivery app. The pantry (repositories) stores and fetches ingredients. Swap the delivery app, renovate the pantry, or change a recipe, and each change stays in its own room.

1. The layers

api/ routersHTTP only: parse, call a service, map results and domain errors to status codes services/business rules; raise domain exceptions; no Request, no HTTPException repositories/ (db/)persistence: queries and sessions, nothing else schemas/ + core/Pydantic models, settings,errors, shared dependencies imported by every layer;imports none of them calls Arrows point one way. A lower layer never imports a higher one, which also prevents circular imports.

2. Build it for real

This example writes a small package with a router, a service, a repository, shared schemas and an app factory, then imports and exercises it:

a layered FastAPI package, written to disk and tested

from pathlib import Path

files = {
    "app/__init__.py": "",
    "app/schemas.py": '''
from pydantic import BaseModel, ConfigDict, Field
class OrderCreate(BaseModel):
    model_config = ConfigDict(extra="forbid")
    sku: str
    qty: int = Field(gt=0, le=50)
class OrderRead(BaseModel):
    id: int
    sku: str
    qty: int
    status: str
''',
    "app/errors.py": '''
class DomainError(Exception): pass
class NotFound(DomainError): pass
class OutOfStock(DomainError): pass
''',
    "app/repository.py": '''
class OrderRepository:                       # in-memory here; SQLAlchemy in lesson 15
    def __init__(self):
        self.rows, self.next_id = {}, 1
    def add(self, sku, qty):
        row = {"id": self.next_id, "sku": sku, "qty": qty, "status": "pending"}
        self.rows[row["id"]] = row
        self.next_id += 1
        return row
    def get(self, order_id):
        return self.rows.get(order_id)
''',
    "app/service.py": '''
from app.errors import NotFound, OutOfStock
STOCK = {"TEA-01": 5, "MLK-02": 0}
class OrderService:                          # business rules, no HTTP anywhere
    def __init__(self, repo):
        self.repo = repo
    def place(self, sku, qty):
        if STOCK.get(sku, 0) < qty:
            raise OutOfStock(f"only {STOCK.get(sku, 0)} of {sku} left")
        STOCK[sku] -= qty
        return self.repo.add(sku, qty)
    def get(self, order_id):
        row = self.repo.get(order_id)
        if row is None:
            raise NotFound(f"order {order_id} not found")
        return row
''',
    "app/api/__init__.py": "",
    "app/api/orders.py": '''
from typing import Annotated
from fastapi import APIRouter, Depends, Request, Response, status
from app.schemas import OrderCreate, OrderRead
from app.service import OrderService

router = APIRouter(prefix="/orders", tags=["orders"])

def get_service(request: Request) -> OrderService:
    return request.app.state.order_service      # built once by the factory

Service = Annotated[OrderService, Depends(get_service)]

@router.post("", status_code=status.HTTP_201_CREATED)
def place_order(body: OrderCreate, service: Service, request: Request, response: Response) -> OrderRead:
    order = service.place(body.sku, body.qty)
    response.headers["Location"] = str(request.url_for("get_order", order_id=order["id"]))
    return order

@router.get("/{order_id}", name="get_order")
def get_order(order_id: int, service: Service) -> OrderRead:
    return service.get(order_id)
''',
    "app/main.py": '''
from fastapi import FastAPI
from fastapi.responses import JSONResponse
from app.api import orders
from app.errors import NotFound, OutOfStock
from app.repository import OrderRepository
from app.service import OrderService

def create_app() -> FastAPI:                    # the app factory: tests call it for a fresh app
    app = FastAPI(title="Orders API")
    app.state.order_service = OrderService(OrderRepository())
    app.include_router(orders.router, prefix="/v1")

    @app.exception_handler(NotFound)              # domain error → HTTP, in exactly one place
    async def not_found(request, exc):
        return JSONResponse({"detail": str(exc)}, status_code=404)

    @app.exception_handler(OutOfStock)
    async def out_of_stock(request, exc):
        return JSONResponse({"detail": str(exc)}, status_code=409)
    return app
''',
}
for name, text in files.items():
    Path(name).parent.mkdir(parents=True, exist_ok=True)
    Path(name).write_text(text.lstrip("\n"), encoding="utf-8")

from fastapi.testclient import TestClient
from app.main import create_app

client = TestClient(create_app())
r = client.post("/v1/orders", json={"sku": "TEA-01", "qty": 2})
print(r.status_code, r.json(), "Location:", r.headers["location"])
print(client.get("/v1/orders/1").json())
print(client.get("/v1/orders/99").status_code, client.get("/v1/orders/99").json())
print(client.post("/v1/orders", json={"sku": "MLK-02", "qty": 1}).json())

app = create_app()
print("app.routes holds:", sorted({type(r).__name__ for r in app.routes}))    # a tree, not a flat list
print("public paths:", sorted(p for p in app.openapi()["paths"]))            # list endpoints this way

Output

201 {'id': 1, 'sku': 'TEA-01', 'qty': 2, 'status': 'pending'} Location: http://testserver/v1/orders/1
{'id': 1, 'sku': 'TEA-01', 'qty': 2, 'status': 'pending'}
404 {'detail': 'order 99 not found'}
{'detail': 'only 0 of MLK-02 left'}
app.routes holds: ['Route', '_IncludedRouter']
public paths: ['/v1/orders', '/v1/orders/{order_id}']
Breaking change in FastAPI 0.137 (June 2026)

include_router() used to copy every route into the parent, so app.routes was a flat list of APIRoutes. Since 0.137 it keeps the included router as a node (_IncludedRouter), so app.routes is a tree. Code that loops over app.routes expecting .path on everything (route listings, home-made metrics labels, OpenAPI patches, some older instrumentation libraries) now breaks, as the first version of this very example did. FastAPI now calls router.routes an internal detail. List endpoints from app.openapi()["paths"], and upgrade instrumentation packages to versions that support 0.137. The upside: routes added to a router after it was included now work.

The router is thin

Two lines per endpoint: call the service, return. Status codes, headers and URL building live here.

The service is pure Python

OrderService can be unit-tested with no FastAPI at all, and reused from a CLI or a worker.

Errors are mapped once

Services raise NotFound; one handler turns it into 404 for every endpoint (part 09).

3. Router features worth knowing

FeatureExampleWhy
prefix, tagsAPIRouter(prefix="/orders", tags=["orders"])URL grouping and docs grouping
router-level dependenciesdependencies=[Depends(require_admin)]protect a whole area in one place
router-level responsesresponses={404: {"model": Problem}}document shared error responses (part 20)
nestingv1 = APIRouter(prefix="/v1"); v1.include_router(orders.router)versioning and composition
name= + url_forrequest.url_for("get_order", order_id=7)no hand-built URLs; survives prefix changes
include_in_schema=Falsehealth checks, internal routeskeep them out of public docs

4. Organise by layer or by feature?

By layer (small to medium services)

app/
api/ orders.py users.py
services/ orders.py users.py
db/ models.py repositories.py
schemas/ orders.py users.py

By feature (large services, many teams)

app/
orders/ router.py service.py
repository.py schemas.py
users/  router.py service.py …
core/   settings.py db.py errors.py

Either works. What matters is that the direction of imports stays one-way, that shared things live in core, and that the choice is consistent. A feature layout scales better once several teams own different parts, because everything about “orders” is in one folder.

5. Why an app factory

Tests get a fresh app

Each test (or test module) calls create_app(settings) with its own configuration, database and overrides. There is no shared global state between tests.

Imports stay side-effect free

Importing app.main does not connect to databases or read secrets. Only calling the factory, or the lifespan (part 10), does.

serving a factory

uvicorn "app.main:create_app" --factory      # uvicorn calls the factory itself
# or keep a module-level instance for tools that need one:   app = create_app()

Recap

  • APIRouter per resource, with prefix, tags, shared dependencies and responses; url_for instead of hand-built URLs.
  • Layers: routers (HTTP) → services (rules, domain errors) → repositories (persistence), with imports pointing downward only.
  • Map domain errors to HTTP once, in exception handlers.
  • An app factory keeps imports side-effect free and gives every test a fresh app.

Get the free RAG production checklist

25 checks to run before real users see your RAG feature: exact-match misses, invented citations, prompt injection through documents, quality that slips after a prompt change. Confirm your email and the PDF comes with the welcome email, plus each new experiment.

One email to confirm. Unsubscribe any time. What's in it · Privacy

Want the production version of this? See ShipRAG: A production RAG + MCP starter kit for FastAPI: hybrid search, validated citations, evals in CI, Docker, 32 tests.

Written by

Alby Tomy

Python/FastAPI Engineer | Data Engineer | AI Engineer building RAG and MCP systems.

X · GitHub · Contact & enquiries