In this post
What this post covers
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
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}']
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
| Feature | Example | Why |
|---|---|---|
prefix, tags | APIRouter(prefix="/orders", tags=["orders"]) | URL grouping and docs grouping |
router-level dependencies | dependencies=[Depends(require_admin)] | protect a whole area in one place |
router-level responses | responses={404: {"model": Problem}} | document shared error responses (part 20) |
| nesting | v1 = APIRouter(prefix="/v1"); v1.include_router(orders.router) | versioning and composition |
name= + url_for | request.url_for("get_order", order_id=7) | no hand-built URLs; survives prefix changes |
include_in_schema=False | health checks, internal routes | keep 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_forinstead 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.