fastapi-backend
by @chempa
Use when building, structuring, reviewing, or scaling a FastAPI backend - layering, async discipline, dependency injection, integration tests in Docker, and production concerns.
Professional FastAPI backends
Opinionated practices for FastAPI services that need to survive more than one developer and more than one release.
The framework is easy to start with and easy to paint yourself into a corner with. Almost every problem in a mature FastAPI codebase traces back to one of four things, so those are what this skill is about:
- Business logic living in route handlers
- Blocking calls inside
async def - Tests that mock the database instead of using one
- Startup work that assumes a single replica
Layering
Four layers, dependencies flowing strictly one way:
router HTTP only. Parse, authorize, call a service, shape the response.
| Knows about status codes. Knows nothing about SQL or business rules.
v
service Business logic. Pure Python, no FastAPI imports, no Request object.
| This is the layer you can unit test without HTTP.
v
repository The only code that talks to the database. Returns domain objects,
| not raw rows or driver cursors.
v
model Persistence shape. Separate from the schemas the API exposes.
The test for whether a router is too fat: could you call the same operation from a CLI command, a background worker, or a queue consumer without touching the router? If not, the logic is in the wrong place. Background sweeps and scheduled jobs importing functions out of a route module is the classic symptom.
Group by feature, not by layer, once you pass roughly ten endpoints:
app/auctions/{router,service,repository,schemas}.py good at scale
app/{routers,services,repositories,schemas}/auctions.py fine when small
A flat routers/ directory with forty modules in it means every feature change touches four distant folders, and nothing can be extracted into its own service later without archaeology.
Details, including settings and dependency injection, in references/structure.md.
Async discipline
This is the single biggest performance lever, and it is counter-intuitive.
| Handler | What FastAPI does | Fails when |
|---|---|---|
async def | Runs on the event loop | Anything inside blocks. One blocking call stalls every request in the process. |
def | Runs in a threadpool (default 40 threads) | Concurrency exceeds the pool. Latency then grows linearly. |
The rule:
- Everything inside can be awaited ->
async def - Something blocks and has no async equivalent -> plain
def, and let
FastAPI move it to the threadpool
- Never put a blocking call inside
async def
A blocking call in async def is worse than a sync endpoint, because the sync one at least gets a thread. What counts as blocking: sync DB drivers, requests, time.sleep, file I/O, bcrypt, any CPU-heavy loop.
# wrong: blocks the loop for every concurrent request
@router.post("/login")
async def login(body: LoginIn):
if bcrypt.checkpw(body.password.encode(), user.hash): # ~100ms of CPU
...
# right: hand it to a worker thread
from starlette.concurrency import run_in_threadpool
@router.post("/login")
async def login(body: LoginIn):
ok = await run_in_threadpool(bcrypt.checkpw, body.password.encode(), user.hash)
CPU-bound work does not belong on the event loop or the threadpool. Push it to a process pool or a queue.
Testing
Covered properly in references/testing.md. The short version, because this is where most FastAPI codebases are weakest:
Integration tests against a real database, in Docker, are the primary test type. Not an extra. For a CRUD-shaped service, a mocked-repository unit test mostly asserts that your mocks are configured the way you configured them.
Use httpx.AsyncClient with ASGITransport, which exercises the real app in-process: middleware, dependency injection, validation, serialization, error handlers. No server, no ports, no flakiness.
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
r = await c.post("/v1/auctions", json=payload, headers=admin_headers)
Non-negotiables:
- Isolation per test. A clean database each test, or a transaction rolled
back after it. Tests that pass only in file order are worse than no tests.
- Authorization is a test case, not a review comment. For every endpoint,
assert the wrong role gets 403 and anonymous gets 401. This is the most commonly skipped and most commonly exploited gap.
- Mock at the boundary only. Third-party HTTP, payment providers, email.
Never mock your own service or repository layer.
- Deterministic time and IDs. Freeze the clock; seed randomness.
Production concerns
references/production.md covers Docker, migrations, observability, and security. The three that bite hardest:
Startup work assumes one replica. Running migrations, index creation, or seeding inside lifespan is fine on a laptop and a race condition on Kubernetes with three pods. Migrations belong in a job that runs once before rollout.
Liveness and readiness are different questions. Liveness means "is this process wedged" and must not touch the database. Readiness means "can it serve traffic" and should. Wiring both to the same DB-checking handler means a brief database blip restarts every pod instead of just removing them from the load balancer.
Multi-stage builds. Dev dependencies, test files, and build tools must not reach the runtime image. Run as a non-root user.
Maintainability
Every rule above is a change-cost rule wearing a FastAPI hat. Worth making that explicit, because it tells you which parts are negotiable.
The layering is a seam, not an aesthetic. The reason the service layer may not import FastAPI is that the import is what would weld your business rules to one delivery mechanism. Keep it out and the same function runs from a worker, a CLI, a test, or whatever replaces FastAPI in six years. That is also why a service raising HTTPException is a real defect rather than a style disagreement.
Enforce the layering mechanically. Layering that lives in review is layering that holds until the first busy week. import-linter, about ten lines in pyproject.toml, turns it into a CI failure:
[importlinter:contract:layers]
name = Layers
type = layers
layers =
app.routers
app.services
app.repositories
Add a forbidden contract for app.services -> fastapi while you are there. Same for cycles, which are cheap to ban early and expensive to ban late.
Version at the boundary so you can delete. /v1 retired as a unit is one negotiation with clients; twenty endpoints retired individually is twenty. Mark deprecated routes, log every call with enough identity to know who is still using them, and delete only after a full quiet cycle. A deprecation that never completes is worse than none, because every reader then has to work out whether the warning is real.
Dependencies drift into a rewrite. Pin exactly, commit the lockfile, and take batched scheduled updates. The cost of upgrading is roughly linear in how far behind you are, right up until it stops being linear.
The test that settles most design arguments: could you delete this feature in an afternoon? A well-bounded one is a package, a router registration, a table, and its tests. If the answer involves untangling fragments from shared helpers, the boundary is wrong, and no amount of local tidying fixes it.
See the maintainability skill for the general versions: the enforcement ladder, blast radius, and the deprecation path.
Copy: no em-dashes
Never use an em-dash (U+2014, the long dash) in user-facing strings: error messages, API descriptions, OpenAPI summaries, log messages. It is the loudest tell that text was generated. A full stop or a colon is almost always the better edit.
Sources
Practices here are cross-checked against current guidance; see references/sources.md for the reading list.