#Non-negotiable
The constraints hoisted below override anything later in this document. Read them first; the rest is rationale.
#Purpose
Rules for building a FastAPI service that stays fast and testable past the first
hundred endpoints. FastAPI gives you validation, DI and docs for free; the cost is
that a wrong choice about async or a leaked ORM model is invisible until load.
Model design is Backend/pydantic; event-loop rules are Backend/python-async;
the ORM is Database/sqlalchemy.
#App factory and feature routers
python# app/main.py — builds the app. No engine creation at import time.
def create_app(settings: Settings) -> FastAPI:
app = FastAPI(title="Orders", version=settings.version, lifespan=lifespan)
app.include_router(orders.router, prefix="/v1/orders", tags=["orders"])
app.include_router(health.router)
return app
# app/orders/router.py
router = APIRouter()
@router.post("", status_code=201, response_model=OrderOut)
async def create_order(body: OrderIn, svc: OrderService = Depends(get_order_service)) -> OrderOut:
return await svc.create(body)
- One
APIRouterper feature; mount withprefixandtagsat include time, not inside the router, so the same router can be mounted under/v2later. - Open connections in
lifespan, never at module import: importingmain.pyfrom a test must not connect to Postgres. - Return
create_app(...)from a factory so tests build an app with test settings.
#Dependencies, not globals
pythondef get_session(request: Request) -> Iterator[Session]:
with request.app.state.sessionmaker() as session:
yield session # code after yield runs after the response
def get_order_service(session: Session = Depends(get_session)) -> OrderService:
return OrderService(OrderRepo(session))
# tests
app.dependency_overrides[get_session] = lambda: fake_session
Depends is the seam. A handler that reaches for a module-level engine cannot be
tested without one, and cannot be given a per-request transaction. Put the
commit in the dependency's teardown or the service — never in the handler.
Annotated[Session, Depends(get_session)] is the modern spelling and lets you
alias it once: SessionDep = Annotated[Session, Depends(get_session)].
#Pydantic at the boundary, ORM in the middle
pythonclass OrderIn(BaseModel):
model_config = ConfigDict(extra="forbid")
items: list[LineItem] = Field(min_length=1)
class OrderOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
total_cents: int
@router.get("/{order_id}", response_model=OrderOut)
async def get_order(order_id: int, svc: ServiceDep) -> OrderOut: ...
- Separate
InandOutmodels. Returning the ORM model leaks columns the moment someone adds one (password_hash,internal_notes). extra="forbid"on input: a typo'd field is a422, not silently ignored.- Always set
response_model(or the return annotation) — it is what filters the output and what the OpenAPI schema documents.
#async def versus def
| Handler body | Declare as | Why |
|---|---|---|
awaits an async driver (asyncpg, httpx.AsyncClient) | async def | Runs on the loop |
Calls a sync library (requests, sync SQLAlchemy, boto3) | def | FastAPI runs it in the threadpool |
| Pure CPU work (hashing, PDF render) | def, or offload | Anything else blocks the loop |
An async def handler that calls requests.get() blocks every request on
the server for the duration of that call. This is the most common FastAPI
performance bug and it does not show up until concurrency. If in doubt, use
def; the threadpool default is 40 workers.
await run_in_threadpool(cpu_bound) for the occasional blocking call inside an
otherwise async handler. → Backend/python-async
#Background work
python@router.post("/{order_id}/receipt", status_code=202)
async def send_receipt(order_id: int, tasks: BackgroundTasks, mailer: MailerDep):
tasks.add_task(mailer.send_receipt, order_id)
return {"queued": True}
BackgroundTasks runs in-process after the response; it dies with the
worker and has no retry. Use it for best-effort work under a second. Anything
that must happen — payments, emails that matter, exports — goes to a real queue.
→ Backend/background-jobs
#Errors
pythonclass OrderNotFound(Exception): ...
@app.exception_handler(OrderNotFound)
async def order_not_found(_: Request, exc: OrderNotFound) -> JSONResponse:
return JSONResponse(status_code=404, content={"detail": "order not found"})
- Raise domain exceptions in services; map them to HTTP in one exception handler
per type.
raise HTTPException(...)inside a service couples it to HTTP. - Override the
RequestValidationErrorhandler if your API has an error envelope; otherwise clients get FastAPI's default shape for422and yours for everything else. - Never let a raw
500traceback reach the client:debug=Falsein production and a catch-all handler that logs with the request id. →Backend/error-handling
#Settings
pythonclass Settings(BaseSettings):
model_config = SettingsConfigDict(env_prefix="APP_", env_file=".env")
database_url: PostgresDsn
debug: bool = False
@lru_cache
def get_settings() -> Settings:
return Settings()
Read the environment once, validate it once, inject it with Depends(get_settings).
A missing APP_DATABASE_URL should fail at startup, not on the first query.
#OpenAPI hygiene
- Every route:
summary,response_model, andresponses={404: {...}}for the error codes it actually returns. The generated spec is your client contract. - Use
tagsper feature andoperation_ids that make good client method names (create_order, notcreate_order_v1_orders_post); setgenerate_unique_id_functiononce on the app. - Disable
/docsand/openapi.jsonon internal services that should not be enumerable:FastAPI(docs_url=None, openapi_url=None).
#Testing
python@pytest.fixture
def client(fake_session):
app = create_app(Settings(database_url="postgresql://test"))
app.dependency_overrides[get_session] = lambda: fake_session
with TestClient(app) as c: # runs lifespan
yield c
def test_create_order_rejects_unknown_field(client):
r = client.post("/v1/orders", json={"items": [], "bogus": 1})
assert r.status_code == 422
TestClient as a context manager runs lifespan; without the with your
startup code never executes. For async handlers that need a real loop, use
httpx.AsyncClient(transport=ASGITransport(app=app)). → Testing/pytest
#Anti-patterns
| Anti-pattern | Why it fails | Fix |
|---|---|---|
| Engine or client created at import | Tests and imports hit the network | Create in lifespan |
Module-level engine used in handlers | Untestable, no per-request transaction | Depends(get_session) |
async def calling requests or sync ORM | Blocks the event loop for everyone | def, or an async driver |
| Returning the ORM object | Leaks new columns automatically | Separate Out model |
No extra="forbid" on input | Typos silently ignored | Forbid extras |
HTTPException raised in a service | Service coupled to HTTP | Domain exception + handler |
BackgroundTasks for must-happen work | Lost on worker death, no retry | A real queue |
Settings() constructed per request | Re-reads env and files each call | @lru_cache |
TestClient(app) without with | lifespan never runs | Context manager |
| Commit inside the handler | Duplicated in every route | Dependency teardown or service |
Auto-generated operation_ids | Unusable client method names | Set an id function |
/docs exposed on internal services | Free API enumeration | docs_url=None |
#Checklist
- Verify:
create_app(settings)factory; no connections at import time - Verify: One
APIRouterper feature, mounted withprefixandtags - Verify: Resources opened in
lifespanand injected viaDepends - Verify: Every input model sets
extra="forbid" - Verify: Every route has a
response_modeldistinct from the ORM model - Verify: Handlers calling sync libraries are
def, notasync def - Verify:
BackgroundTasksused only for best-effort work - Verify: Domain exceptions mapped to HTTP in exception handlers
- Verify:
RequestValidationErrorhandler matches the API's error envelope - Verify: Settings validated once at startup and cached
- Verify: Routes declare
summary,responses, and stableoperation_ids - Verify: Docs disabled on services that must not be enumerable
- Verify: Tests use
dependency_overridesandTestClientas a context manager