FastAPI Architecture
Targets FastAPI 0.136 on Python 3.14. Companion to python-architect and sql-architect (data access via psycopg + .sql files). Implementation skeletons in RECIPES.md; pinned deps in STACK.md.
1. Project structure — feature-based
One folder per bounded context. Each feature owns its router, service, repo, schemas, and SQL files. Full tree in RECIPES.md.
router.py depends on service.py; never reaches into repo.py directly.
service.py is pure Python — no FastAPI imports. Easy to unit-test.
schemas.py holds Pydantic models — never reused as ORM models or DB rows.
2. Routing & versioning
- URL-prefix versioning:
/v1/users, /v1/orders. Mount each version's routers under a v1_router = APIRouter(prefix="/v1"). Deprecate by mounting /v2 alongside, never by mutating /v1.
- One
APIRouter per feature, included in main.py.
- Tags match feature folder names (
tags=["users"]) — drives OpenAPI grouping.
- Path parameter types in the signature (
user_id: UUID) — FastAPI validates and parses for free.
- Response model declared per route (
response_model=UserResponse) — sets the contract and trims extra fields automatically.
- Status codes explicit (
status_code=status.HTTP_201_CREATED).
3. Pydantic schemas — separate request and response
Three shapes per resource: <Resource>Create (POST body), <Resource>Update (PATCH partial), <Resource>Response (response body). Example in RECIPES.md.
extra="forbid" on every request model. Unknown fields are an error, not silent acceptance.
SecretStr / SecretBytes for passwords, tokens. Stops accidental logging.
Field(..., examples=[...]) drives OpenAPI examples — clients get usable defaults.
- Never reuse the same model for request and response. Read-only fields leak into PATCH payloads otherwise.
- Pydantic v2 validators:
@field_validator for per-field, @model_validator(mode="after") for cross-field invariants.
4. Dependency injection
- Single source of shared state via
Depends. DB connections, HTTP clients, auth subjects — all injected, never imported as module-level globals.
- Async dependencies for anything I/O-bound:
async def get_db() -> AsyncIterator[AsyncConnection]: ....
- Sub-dependencies for layered composition:
get_current_user depends on decode_token depends on get_settings. FastAPI resolves the graph and caches per-request.
- Use type aliases to keep route signatures clean (see RECIPES.md).
5. Lifespan & startup
Lifespan context is the only place to open/close shared resources (DB pool, HTTP client, cache, message bus). Never in module-level code or @app.on_event (deprecated). Settings loaded at startup, validated once via pydantic-settings. Skeleton in RECIPES.md.
6. Authentication & authorization
Patterns (in-house JWT vs external IdP, Argon2id, JWT lifetimes, JWKS verification, switching criterion) live in rest-api-architect/AUTH_PATTERNS.md. FastAPI-specific implementation:
- Pattern A — in-house OAuth2 + JWT uses FastAPI's
OAuth2PasswordBearer + pyjwt + argon2-cffi. Dependency skeleton in RECIPES.md.
- Pattern B — external IdP uses
pyjwt's PyJWKClient for JWKS verification; cache via @lru_cache. Verify aud and iss explicitly.
- Authorization is route-level via dependencies, not middleware —
dependencies=[Depends(require_scope("users:delete"))] on the route. Skeleton in RECIPES.md.
7. Error handling — RFC 7807 Problem Details
Every error returns application/problem+json with a standardised shape (per rest-api-architect §7). Handler skeleton in RECIPES.md.
- One handler per domain-exception family. Never let
HTTPException and your custom exceptions return different shapes.
- Validation errors (
RequestValidationError) get their own handler that maps Pydantic's error list into Problem.detail.
- Never leak stack traces in
detail. Log them server-side with a correlation id; reference the id in the response.
8. Middleware
Order matters — outermost middleware sees the request first.
- CORS (
CORSMiddleware) — first, so preflights short-circuit before auth.
- Compression (
GZipMiddleware, min_size=1000).
- Request ID (custom) — generate a UUID per request, attach to logs and response header.
- Logging (custom) — structured logs with method, path, status, latency, request id.
- Auth is a dependency, not middleware — per-route, lets unauthenticated endpoints (login, health) coexist cleanly.
9. Background tasks
BackgroundTasks for genuinely fire-and-forget work that's tied to one response (sending a confirmation email, writing a metric). The task runs after the response is sent but in the same process — failures are invisible to the client.
- Anything serious (retryable, distributed, scheduled) belongs in a real task queue — flag for a future
task-queue-architect skill. BackgroundTasks is not a queue.
10. Testing
TestClient for end-to-end synchronous tests against the ASGI app.
httpx.AsyncClient with ASGITransport for async tests that need to exercise async dependencies fully.
- Override dependencies in tests via
app.dependency_overrides[get_db] = .... Reset after the test.
- DB fixtures: run migrations into a per-test schema, or wrap each test in a rolled-back transaction (faster).
- Snapshot the OpenAPI spec in CI:
assert app.openapi() == json.load(open("tests/openapi.snapshot.json")). Catches accidental contract changes.
11. OpenAPI & docs
- Tags, summaries, descriptions on every route. They drive the rendered docs and SDK code generation.
responses={...} to document non-default status codes with their shapes (401, 403, 404, 422).
include_in_schema=False on internal endpoints (health, metrics, debug).
- Customise the spec in
app.openapi() to add info.contact, servers, securitySchemes — these aren't FastAPI defaults.
12. Performance
- All routes are
async def unless they call a synchronous library and you've decided not to wrap it.
- Never
time.sleep, requests, or other blocking calls inside async def. Block-detection: asyncio.get_event_loop().slow_callback_duration = 0.1 in dev.
- Run sync I/O in a thread:
await asyncio.to_thread(blocking_fn, args).
- Connection pooling: open the DB pool once in
lifespan (see §5); never psycopg.connect() per request.
response_model_exclude_unset=True when returning a large model with many optional fields — avoids serialising defaults.
- Pagination at the API layer mirrors the SQL pattern (see sql-architect §4): cursor over offset.
1---2name: fastapi-architect3description: Framework-specific delta on rest-api-architect — FastAPI 0.136 on Python 3.14. Feature layout, Pydantic v2 request/response separation, async DI with lifespan, URL-prefix versioning, RFC 7807 errors, in-house OAuth2+JWT or external IdP. Read rest-api-architect first for the cross-cutting REST conventions. Use when scaffolding or reviewing a FastAPI service.4---56# FastAPI Architecture78Targets **FastAPI 0.136** on **Python 3.14**. Companion to [python-architect](../../languages/python-architect/SKILL.md) and [sql-architect](../../databases/sql-architect/SKILL.md) (data access via `psycopg + .sql files`). Implementation skeletons in [RECIPES.md](RECIPES.md); pinned deps in [STACK.md](STACK.md).910## 1. Project structure — feature-based1112One folder per bounded context. Each feature owns its router, service, repo, schemas, and SQL files. Full tree in [RECIPES.md](RECIPES.md).1314- **`router.py`** depends on `service.py`; never reaches into `repo.py` directly.15- **`service.py`** is pure Python — no FastAPI imports. Easy to unit-test.16- **`schemas.py`** holds Pydantic models — never reused as ORM models or DB rows.1718## 2. Routing & versioning1920- **URL-prefix versioning:** `/v1/users`, `/v1/orders`. Mount each version's routers under a `v1_router = APIRouter(prefix="/v1")`. Deprecate by mounting `/v2` alongside, never by mutating `/v1`.21- **One `APIRouter` per feature**, included in `main.py`.22- **Tags** match feature folder names (`tags=["users"]`) — drives OpenAPI grouping.23- **Path parameter types in the signature** (`user_id: UUID`) — FastAPI validates and parses for free.24- **Response model declared per route** (`response_model=UserResponse`) — sets the contract and trims extra fields automatically.25- **Status codes explicit** (`status_code=status.HTTP_201_CREATED`).2627## 3. Pydantic schemas — separate request and response2829Three shapes per resource: `<Resource>Create` (POST body), `<Resource>Update` (PATCH partial), `<Resource>Response` (response body). Example in [RECIPES.md](RECIPES.md).3031- **`extra="forbid"`** on every request model. Unknown fields are an error, not silent acceptance.32- **`SecretStr` / `SecretBytes`** for passwords, tokens. Stops accidental logging.33- **`Field(..., examples=[...])`** drives OpenAPI examples — clients get usable defaults.34- **Never reuse the same model for request and response.** Read-only fields leak into PATCH payloads otherwise.35- **Pydantic v2 validators:** `@field_validator` for per-field, `@model_validator(mode="after")` for cross-field invariants.3637## 4. Dependency injection3839- **Single source of shared state via `Depends`.** DB connections, HTTP clients, auth subjects — all injected, never imported as module-level globals.40- **Async dependencies** for anything I/O-bound: `async def get_db() -> AsyncIterator[AsyncConnection]: ...`.41- **Sub-dependencies** for layered composition: `get_current_user` depends on `decode_token` depends on `get_settings`. FastAPI resolves the graph and caches per-request.42- **Use type aliases** to keep route signatures clean (see [RECIPES.md](RECIPES.md)).4344## 5. Lifespan & startup4546Lifespan context is the only place to open/close shared resources (DB pool, HTTP client, cache, message bus). Never in module-level code or `@app.on_event` (deprecated). Settings loaded at startup, validated once via `pydantic-settings`. Skeleton in [RECIPES.md](RECIPES.md).4748## 6. Authentication & authorization4950**Patterns** (in-house JWT vs external IdP, Argon2id, JWT lifetimes, JWKS verification, switching criterion) live in [rest-api-architect/AUTH_PATTERNS.md](../../protocols/rest-api-architect/AUTH_PATTERNS.md). FastAPI-specific implementation:5152- **Pattern A — in-house OAuth2 + JWT** uses FastAPI's `OAuth2PasswordBearer` + `pyjwt` + `argon2-cffi`. Dependency skeleton in [RECIPES.md](RECIPES.md).53- **Pattern B — external IdP** uses `pyjwt`'s `PyJWKClient` for JWKS verification; cache via `@lru_cache`. Verify `aud` and `iss` explicitly.54- **Authorization is route-level via dependencies, not middleware** — `dependencies=[Depends(require_scope("users:delete"))]` on the route. Skeleton in [RECIPES.md](RECIPES.md).5556## 7. Error handling — RFC 7807 Problem Details5758Every error returns `application/problem+json` with a standardised shape (per [rest-api-architect §7](../../protocols/rest-api-architect/SKILL.md#7-error-contracts--rfc-7807-problem-details)). Handler skeleton in [RECIPES.md](RECIPES.md).5960- **One handler per domain-exception family.** Never let `HTTPException` and your custom exceptions return different shapes.61- **Validation errors** (`RequestValidationError`) get their own handler that maps Pydantic's error list into `Problem.detail`.62- **Never leak stack traces** in `detail`. Log them server-side with a correlation id; reference the id in the response.6364## 8. Middleware6566Order matters — outermost middleware sees the request first.67681. **CORS** (`CORSMiddleware`) — first, so preflights short-circuit before auth.692. **Compression** (`GZipMiddleware`, min_size=1000).703. **Request ID** (custom) — generate a UUID per request, attach to logs and response header.714. **Logging** (custom) — structured logs with method, path, status, latency, request id.725. **Auth** is a **dependency**, not middleware — per-route, lets unauthenticated endpoints (login, health) coexist cleanly.7374## 9. Background tasks7576- **`BackgroundTasks`** for genuinely fire-and-forget work that's tied to one response (sending a confirmation email, writing a metric). The task runs after the response is sent but in the same process — failures are invisible to the client.77- **Anything serious** (retryable, distributed, scheduled) belongs in a real task queue — flag for a future `task-queue-architect` skill. `BackgroundTasks` is not a queue.7879## 10. Testing8081- **`TestClient`** for end-to-end synchronous tests against the ASGI app.82- **`httpx.AsyncClient` with `ASGITransport`** for async tests that need to exercise async dependencies fully.83- **Override dependencies in tests** via `app.dependency_overrides[get_db] = ...`. Reset after the test.84- **DB fixtures:** run migrations into a per-test schema, or wrap each test in a rolled-back transaction (faster).85- **Snapshot the OpenAPI spec** in CI: `assert app.openapi() == json.load(open("tests/openapi.snapshot.json"))`. Catches accidental contract changes.8687## 11. OpenAPI & docs8889- **Tags, summaries, descriptions on every route.** They drive the rendered docs and SDK code generation.90- **`responses={...}`** to document non-default status codes with their shapes (`401`, `403`, `404`, `422`).91- **`include_in_schema=False`** on internal endpoints (health, metrics, debug).92- **Customise the spec** in `app.openapi()` to add `info.contact`, `servers`, `securitySchemes` — these aren't FastAPI defaults.9394## 12. Performance9596- **All routes are `async def`** unless they call a synchronous library and you've decided not to wrap it.97- **Never `time.sleep`, `requests`, or other blocking calls inside `async def`.** Block-detection: `asyncio.get_event_loop().slow_callback_duration = 0.1` in dev.98- **Run sync I/O in a thread:** `await asyncio.to_thread(blocking_fn, args)`.99- **Connection pooling:** open the DB pool once in `lifespan` (see §5); never `psycopg.connect()` per request.100- **`response_model_exclude_unset=True`** when returning a large model with many optional fields — avoids serialising defaults.101- **Pagination** at the API layer mirrors the SQL pattern (see [sql-architect §4](../../databases/sql-architect/SKILL.md)): cursor over offset.