FastAPI patterns that survive past fifty endpoints
The framework makes the first endpoint delightful. The fiftieth is a question of discipline — these are the decisions that matter.
FastAPI makes the first endpoint delightful and the fiftieth one a question of discipline. The framework doesn't impose much structure, which is why the difference between a FastAPI codebase that's pleasant at scale and one that isn't comes down to a handful of decisions made early.
These are the patterns we apply on every service we build.
Structure by domain, not by layer
The tutorial layout — routers/, models/, schemas/, crud/ — works until roughly a dozen endpoints. After that, every feature change touches four directories and no directory tells you what the application does.
app/
invoicing/
router.py # HTTP surface
service.py # business logic
models.py # SQLAlchemy
schemas.py # Pydantic
tests/
customers/
router.py
service.py
...
core/
config.py
database.py
security.py
Each package owns its slice end to end. A new engineer opening app/ sees the business domains rather than a taxonomy of Python patterns, and a feature change usually stays inside one folder.
Keep routers thin
A route handler should do three things: accept validated input, call a service, return a response model. Business logic in the handler is logic you can only exercise through HTTP.
@router.post("/invoices", response_model=InvoiceOut, status_code=201)
async def create_invoice(
payload: InvoiceCreate,
service: InvoiceService = Depends(get_invoice_service),
) -> InvoiceOut:
return await service.create(payload)
The payoff shows up when the same operation needs to run from a background worker, a CLI command or a scheduled job. If the logic lives in InvoiceService, that's an import. If it lives in the route, it's a rewrite or an HTTP call to yourself.
Dependency injection is the testing story
FastAPI's Depends is the cleanest DI system in the Python web ecosystem, and it's most valuable for what it does to tests rather than what it does to request handling.
# Composable, and each layer is independently overridable
def get_invoice_service(
session: AsyncSession = Depends(get_session),
tenant: Tenant = Depends(get_current_tenant),
) -> InvoiceService:
return InvoiceService(session, tenant)
In tests, app.dependency_overrides swaps any of these for a fake without patching module internals. Tests that use unittest.mock.patch on import paths break every time you move a file; dependency overrides don't.
Separate the input, database and output models
It's tempting to use one Pydantic model for the request body, the ORM row and the response. It works right up until the moment you add an internal field and accidentally return it to every API consumer.
Three models, deliberately:
InvoiceCreate— what a client may send. Noid, notenant_id, nocreated_by.Invoice— the SQLAlchemy model. Internal fields live here and nowhere else.InvoiceOut— what clients receive. Adding a column to the table cannot leak it.
The duplication is real and it's worth it. It's the difference between "we added an internal cost field" being a database migration and being a security incident.
Async everywhere, or nowhere
The most common FastAPI performance bug we're called in to fix is a synchronous blocking call inside an async def handler. A blocking database driver or a requests call blocks the whole event loop — not just that request — and throughput collapses under concurrency in a way that's invisible in local testing.
Pick a lane per route and hold it:
async defhandlers with async drivers throughout —asyncpg,httpx, async SQLAlchemy.- Or plain
defhandlers, which FastAPI correctly runs in a threadpool, with ordinary synchronous libraries.
Both are fine. Mixing them inside one handler is the problem.
Worth knowing: CPU-bound work blocks the event loop just as effectively as blocking I/O. Image processing, large PDF parsing and heavy serialisation belong in a worker process, not in an async handler.
Configuration as a typed object
Reading os.environ at the point of use means a missing variable becomes a runtime error, in production, at 3am, on the one code path nobody exercised.
class Settings(BaseSettings):
database_url: PostgresDsn
redis_url: RedisDsn
jwt_secret: SecretStr
environment: Literal["local", "staging", "production"] = "local"
model_config = SettingsConfigDict(env_file=".env")
Instantiate once at startup. A misconfigured environment now fails immediately and loudly, with the name of the offending variable, instead of failing subtly later.
Errors that mean something
Raise domain exceptions from services; translate them to HTTP at the edge with a single exception handler. Services stay free of HTTP concepts, and error responses stay consistent across the whole API rather than depending on which developer wrote which endpoint.
@app.exception_handler(DomainError)
async def domain_error_handler(request: Request, exc: DomainError):
return JSONResponse(
status_code=exc.status_code,
content={"error": exc.code, "detail": exc.message},
)
One shape for every error, one place to add a correlation ID, and one place to decide what gets logged versus what gets returned.
Migrations are not optional
Base.metadata.create_all() is fine for a tutorial and a liability in a real deployment. Alembic from the first commit — including the empty initial migration. The cost of adding migrations to a schema that's already live in production is materially higher than the cost of having them from the start.
Observability from day one
Three things, all cheap to add early and irritating to add later:
- Structured JSON logs with a request ID propagated through every layer, so one user's failing request can be traced end to end.
- A real health check that verifies database and cache connectivity, not one that returns
{"status": "ok"}unconditionally. An orchestrator can only act on truthful health. - Request metrics — latency histograms and error rates per route. You cannot fix what you cannot see, and "the API feels slow" is not a diagnosis.
The short version
Domain packages, thin routers, services holding the logic, DI for testability, three-model separation, one async discipline per route, typed settings, centralised error translation, Alembic from commit one, and structured logs with request IDs.
None of it is exotic. All of it is much cheaper on day one than on day four hundred — which is really the only argument that matters.
Working on something like this?
Book a free 30-minute call. It's a working session with an engineer — bring the problem and we'll tell you what it would realistically take to build.
Related reading
All articles →Where AI automation actually pays: choosing your first process
Most companies pick the process that annoys them most rather than the one that costs the most. Those are rarely the same thing.
AIWhat it takes to run AI agents in production
An agent demo takes an afternoon. An agent you'd let touch a customer record takes considerably longer — and almost none of the extra work is prompt engineering.
SaaS & ProductMulti-tenancy: the SaaS decision you can't easily undo
Cheapest to make on day one, most expensive to change on day four hundred. Here's how to choose, and what breaks at tenant fifty.
Ready to Build Your Next AI Product?
Book a free 30-minute call. We'll go through your requirements and tell you what it would realistically take to build.