FastAPI Conventions
Rules for FastAPI services on the Python secondary stack (AI/ML serving, data pipelines,
client-mandated projects). A FastAPI service shares the house infrastructure — PostgreSQL,
Redis, Centrifugo — and speaks the same API contract as the Rails backend.
Stack
| Concern |
Library |
| Framework |
FastAPI (latest) on uvicorn — deployed to ECS Fargate behind an ALB |
| ORM / migrations |
SQLAlchemy 2.0 async (select(), Mapped[], mapped_column) + Alembic |
| Validation / config |
pydantic v2 + pydantic-settings |
| Background jobs |
Celery with the Redis broker |
| Real-time |
Centrifugo (house standard — do not add Socket.IO) |
| Pagination |
fastapi-pagination |
| Auth |
OAuth2 bearer + pyjwt + argon2-cffi — NOT python-jose, NOT passlib (both unmaintained) |
| Tooling |
uv (packages), ruff (lint + format), mypy strict, pytest |
Project Structure
app/
main.py # create_app() factory + lifespan context manager (engine, clients)
core/config.py # pydantic-settings Settings — env-driven, never hardcoded secrets
api/routers/ # One module per resource: users.py, orders.py
schemas/ # Pydantic request/response models, mirrored per resource
models/ # SQLAlchemy models — a package, one module per aggregate
services/ # Business logic — plain classes/functions, no HTTP awareness
db/session.py # Async engine + async_sessionmaker + get_session dependency
alembic/ # Migrations — safety rules owned by std-database
app/models/ is a package with one module per aggregate — never a single models.py.
A lone models.py is the Django idiom and that filename is claimed by the std-django
skill's paths; the package layout keeps the two skills off each other's files.
- Wire startup/shutdown in
lifespan, not deprecated @app.on_event handlers.
Routers
- One
APIRouter per resource with explicit prefix and tags:
router = APIRouter(prefix="/orders", tags=["orders"]), included from create_app().
response_model is always explicit — it is the serialization contract; without it
FastAPI returns whatever the function returns, leaking ORM internals and extra fields.
- Status codes are explicit:
status_code=201 on create, 204 on delete — the 200
default on a POST misreports what happened and clients key retry logic off status.
- Routers never contain business logic — parse, authorize, delegate to a service,
serialize. Same rule as thin Rails controllers; logic in a route cannot be reused by
Celery tasks or scripts.
- Auth and the DB session arrive via
Depends — never construct a session inside a route.
@router.post("", response_model=OrderRead, status_code=201)
async def create_order(
payload: OrderCreate,
session: AsyncSession = Depends(get_session),
user: User = Depends(get_current_user),
) -> OrderRead:
order = await OrderService(session).create(payload, actor=user)
return OrderRead.model_validate(order)
Schemas (pydantic v2)
- Separate
XCreate / XUpdate / XRead models per resource — one shared model forces
every field optional and lets clients write server-managed fields.
- Read models set
model_config = ConfigDict(from_attributes=True) and are built with
XRead.model_validate(orm_obj).
- Never return an ORM object from a route — the
response_model + read model is the
boundary; a raw ORM object couples the wire format to the table and can lazy-load
outside the session.
- Use v2 idioms only:
model_dump() / model_dump_json(), never the v1 .dict() / .json().
- Update models declare optional fields and apply with
model_dump(exclude_unset=True)
so PATCH distinguishes "omitted" from "set to null".
Dependency Injection
Depends wires the session (get_session yields from async_sessionmaker, one session
per request), the current user (bearer token → get_current_user), and services.
- No import-time singletons except settings — a module-level engine or HTTP client
binds config and event loop at import, breaking test overrides and worker forks. Build
them in
lifespan and reach them through dependencies.
- Settings come from one
@lru_cache-wrapped get_settings() returning the
pydantic-settings Settings — env-driven, never hardcoded (secret handling owned by
std-security).
Async
- Routes are
async def with the async session end-to-end (postgresql+asyncpg://) —
do not mix a sync engine into an async app.
- Sync-only libraries (boto3, some SDKs) go through
run_in_threadpool from
starlette.concurrency.
- Never call blocking I/O in an async route —
requests, time.sleep, a sync driver —
it stalls the event loop for every in-flight request, not just this one. Deep guidance
owned by std-python-performance.
Errors
HTTPException only in routers; services raise domain exceptions
(OrderNotFoundError), never HTTP — services must stay callable from Celery and scripts.
- ONE app-level exception handler (
app.exception_handler) translates domain
exceptions into the house JSON error envelope owned by std-api-design — one
translation point, do not restate the envelope or hand-build error JSON per route.
Background Jobs (Celery + Redis)
- Mirror the Sidekiq conventions: tasks are idempotent — Celery retries deliver
at-least-once, so a non-idempotent task double-charges on redelivery.
- Pass IDs, not objects — re-fetch inside the task; a serialized object is stale by
the time the worker runs it.
- Queues are named
default, critical, low_priority — the same names as the Sidekiq
queues, so operators reason about one queue taxonomy across both stacks.
- Set explicit retry limits and
acks_late=True; JSON serializer only, never pickle.
Testing
httpx.AsyncClient with ASGITransport(app=app) — in-process, no live server, no ports.
app.dependency_overrides swaps get_session for a rollback-per-test session and
get_current_user for a stub user — never patch auth internals.
pytest-asyncio with asyncio_mode = "auto"; AAA structure and coverage targets owned
by std-testing.
Related, owned elsewhere — do not duplicate: the JSON error envelope and pagination
response format live in std-api-design; migration safety and indexing depth in
std-database; OWASP and secret management in std-security; structured logging and
PII-in-logs in std-monitoring; AAA and coverage targets in std-testing; general Python
layout, typing, and layering in std-python; blocking-I/O and ORM query performance in
std-python-performance; Django specifics in std-django; ML/LLM serving conventions in
std-python-ai-ml.
1---2name: std-fastapi3description: FastAPI service conventions — routers, Pydantic schemas, SQLAlchemy 2.0 + Alembic, dependency injection, Celery background jobs, the house error envelope. Use when building or reviewing FastAPI APIs.4paths:5 - "**/app/main.py"6 - "**/app/api/**/*.py"7 - "**/app/routers/**/*.py"8 - "**/app/schemas/**/*.py"9 - "**/app/services/**/*.py"10 - "**/app/core/**/*.py"11 - "**/app/db/**/*.py"12 - "**/alembic.ini"13 - "**/alembic/**/*.py"14---1516# FastAPI Conventions1718Rules for FastAPI services on the Python secondary stack (AI/ML serving, data pipelines,19client-mandated projects). A FastAPI service shares the house infrastructure — PostgreSQL,20Redis, Centrifugo — and speaks the same API contract as the Rails backend.2122## Stack2324| Concern | Library |25|---------|---------|26| Framework | **FastAPI** (latest) on uvicorn — deployed to ECS Fargate behind an ALB |27| ORM / migrations | SQLAlchemy 2.0 async (`select()`, `Mapped[]`, `mapped_column`) + Alembic |28| Validation / config | pydantic v2 + pydantic-settings |29| Background jobs | Celery with the Redis broker |30| Real-time | Centrifugo (house standard — do **not** add Socket.IO) |31| Pagination | fastapi-pagination |32| Auth | OAuth2 bearer + `pyjwt` + `argon2-cffi` — **NOT `python-jose`, NOT `passlib`** (both unmaintained) |33| Tooling | uv (packages), ruff (lint + format), mypy strict, pytest |3435## Project Structure3637```38app/39 main.py # create_app() factory + lifespan context manager (engine, clients)40 core/config.py # pydantic-settings Settings — env-driven, never hardcoded secrets41 api/routers/ # One module per resource: users.py, orders.py42 schemas/ # Pydantic request/response models, mirrored per resource43 models/ # SQLAlchemy models — a package, one module per aggregate44 services/ # Business logic — plain classes/functions, no HTTP awareness45 db/session.py # Async engine + async_sessionmaker + get_session dependency46alembic/ # Migrations — safety rules owned by std-database47```4849- **`app/models/` is a package with one module per aggregate — never a single `models.py`.**50 A lone `models.py` is the Django idiom and that filename is claimed by the `std-django`51 skill's paths; the package layout keeps the two skills off each other's files.52- Wire startup/shutdown in `lifespan`, not deprecated `@app.on_event` handlers.5354## Routers5556- One `APIRouter` per resource with explicit `prefix` and `tags`:57 `router = APIRouter(prefix="/orders", tags=["orders"])`, included from `create_app()`.58- **`response_model` is always explicit** — it is the serialization contract; without it59 FastAPI returns whatever the function returns, leaking ORM internals and extra fields.60- **Status codes are explicit**: `status_code=201` on create, `204` on delete — the 20061 default on a POST misreports what happened and clients key retry logic off status.62- **Routers never contain business logic** — parse, authorize, delegate to a service,63 serialize. Same rule as thin Rails controllers; logic in a route cannot be reused by64 Celery tasks or scripts.65- Auth and the DB session arrive via `Depends` — never construct a session inside a route.6667```python68@router.post("", response_model=OrderRead, status_code=201)69async def create_order(70 payload: OrderCreate,71 session: AsyncSession = Depends(get_session),72 user: User = Depends(get_current_user),73) -> OrderRead:74 order = await OrderService(session).create(payload, actor=user)75 return OrderRead.model_validate(order)76```7778## Schemas (pydantic v2)7980- Separate `XCreate` / `XUpdate` / `XRead` models per resource — one shared model forces81 every field optional and lets clients write server-managed fields.82- Read models set `model_config = ConfigDict(from_attributes=True)` and are built with83 `XRead.model_validate(orm_obj)`.84- **Never return an ORM object from a route** — the `response_model` + read model is the85 boundary; a raw ORM object couples the wire format to the table and can lazy-load86 outside the session.87- Use v2 idioms only: `model_dump()` / `model_dump_json()`, never the v1 `.dict()` / `.json()`.88- Update models declare optional fields and apply with `model_dump(exclude_unset=True)`89 so PATCH distinguishes "omitted" from "set to null".9091## Dependency Injection9293- `Depends` wires the session (`get_session` yields from `async_sessionmaker`, one session94 per request), the current user (bearer token → `get_current_user`), and services.95- **No import-time singletons except settings** — a module-level engine or HTTP client96 binds config and event loop at import, breaking test overrides and worker forks. Build97 them in `lifespan` and reach them through dependencies.98- Settings come from one `@lru_cache`-wrapped `get_settings()` returning the99 pydantic-settings `Settings` — env-driven, never hardcoded (secret handling owned by100 `std-security`).101102## Async103104- Routes are `async def` with the async session end-to-end (`postgresql+asyncpg://`) —105 do not mix a sync engine into an async app.106- Sync-only libraries (boto3, some SDKs) go through `run_in_threadpool` from107 `starlette.concurrency`.108- **Never call blocking I/O in an async route** — `requests`, `time.sleep`, a sync driver —109 it stalls the event loop for every in-flight request, not just this one. Deep guidance110 owned by `std-python-performance`.111112## Errors113114- `HTTPException` only in routers; services raise domain exceptions115 (`OrderNotFoundError`), never HTTP — services must stay callable from Celery and scripts.116- **ONE app-level exception handler** (`app.exception_handler`) translates domain117 exceptions into the house JSON error envelope owned by `std-api-design` — one118 translation point, do not restate the envelope or hand-build error JSON per route.119120## Background Jobs (Celery + Redis)121122- Mirror the Sidekiq conventions: tasks are **idempotent** — Celery retries deliver123 at-least-once, so a non-idempotent task double-charges on redelivery.124- **Pass IDs, not objects** — re-fetch inside the task; a serialized object is stale by125 the time the worker runs it.126- Queues are named `default`, `critical`, `low_priority` — the same names as the Sidekiq127 queues, so operators reason about one queue taxonomy across both stacks.128- Set explicit retry limits and `acks_late=True`; JSON serializer only, never pickle.129130## Testing131132- `httpx.AsyncClient` with `ASGITransport(app=app)` — in-process, no live server, no ports.133- `app.dependency_overrides` swaps `get_session` for a rollback-per-test session and134 `get_current_user` for a stub user — never patch auth internals.135- `pytest-asyncio` with `asyncio_mode = "auto"`; AAA structure and coverage targets owned136 by `std-testing`.137138Related, owned elsewhere — do not duplicate: the JSON error envelope and pagination139response format live in `std-api-design`; migration safety and indexing depth in140`std-database`; OWASP and secret management in `std-security`; structured logging and141PII-in-logs in `std-monitoring`; AAA and coverage targets in `std-testing`; general Python142layout, typing, and layering in `std-python`; blocking-I/O and ORM query performance in143`std-python-performance`; Django specifics in `std-django`; ML/LLM serving conventions in144`std-python-ai-ml`.