What a "production-ready" FastAPI starter actually needs
Every FastAPI project I've started begins the same way: an hour of
boilerplate before I write a single line of actual logic. Auth. A
database session dependency. A folder structure that won't fall apart
once there's more than one resource. A test setup that doesn't take
longer to configure than the tests themselves.
I got tired of rebuilding it, so I built it once, properly, and wrote
down why each piece is shaped the way it is.
The structure
Every resource in the project follows the same four layers:
- Router — HTTP in/out only. Parses the request, calls a service, serializes the response. No business logic lives here.
- Service — business rules. Ownership checks, "does this already exist" decisions, orchestration. No FastAPI imports — this layer doesn't know it's running inside a web framework.
- Repository — persistence only. SELECT/INSERT/UPDATE/DELETE via SQLAlchemy. No business rules.
- Schema — Pydantic models for request/response shapes, kept separate from the ORM models.
This feels like overkill for a single resource. It stops feeling that
way the first time you need the same ownership check enforced in two
different routes, or the first time you want to unit-test a business
rule without spinning up the whole ASGI app to do it.
The decisions that actually mattered
Testing against real Postgres, not SQLite. A SQLite-backed test
suite gives you false confidence — native UUID types, enum handling,
and constraint behavior all differ enough that "tests pass" stops
meaning "the Postgres-specific code works." Each test runs inside a
SAVEPOINT that gets rolled back afterward, so isolation doesn't cost
a schema rebuild per test.
Two token types, not one. Short-lived access tokens (15 min) plus
longer-lived refresh tokens (30 days), with the token's type claim
checked on every decode — a refresh token presented where an access
token is expected gets rejected on that alone, not just on signature
validity.
One error shape, always. A raised domain exception, a FastAPI
validation error, and an unhandled 500 all come back as
{"error": {"code": ..., "message": ..., "details": ...}}. A frontend
or API client shouldn't need three different error-handling code
paths depending on which layer failed.
Argon2, not bcrypt. Not because bcrypt is broken, but because
passlib — the usual FastAPI-tutorial wrapper around it — has been
effectively unmaintained and throws deprecation warnings on current
Python. argon2-cffi directly, no wrapper.
What I didn't build
No Stripe integration, no multi-tenancy, no admin panel, no frontend.
Not because they're hard — because bundling them in means paying for
scope you don't need yet, and a starter kit that tries to be
everything ends up being a worse version of each thing. This is
meant to be the backend those get built on top of, not a replacement
for building them properly when you actually need them.
If you want the whole thing
I packaged this into Baseline — JWT auth, async SQLAlchemy 2.0 +
PostgreSQL, the layered structure above with a fully-wired example
resource, a 20-test suite, Docker Compose, and GitHub Actions CI,
documented rather than just dumped. $12, unlimited personal and
commercial projects: [https://hassantak.gumroad.com/l/baseline]
Happy to answer questions about any of the decisions above in the
comments — including the ones I'd probably do differently next time.
I packaged this into Baseline — JWT auth, async SQLAlchemy 2.0 +
PostgreSQL, the layered structure above with a fully-wired example
resource, a 20-test suite, Docker Compose, and GitHub Actions CI,
documented rather than just dumped. $12, unlimited personal and
commercial projects: [link to your Gumroad listing]
Happy to answer questions about any of the decisions above in the
comments — including the ones I'd probably do differently next time.``
Top comments (0)