Almost every FastAPI project I inherit started as one main.py, and almost every one of them is being painfully un-picked at exactly the point it needed to grow. The structure you scaffold on day one is the structure you live with for two years, so scaffold the one you can grow into rather than the one you refactor out of.
FastAPI’s tutorials are single-file for a reason: they teach the framework, not the project. That is fine until the second resource, the second developer, or the first person who asks where configuration lives. This is the layout I reach for on FastAPI 0.115 with Pydantic 2, Python 3.12 and uv — opinionated, boring, and shaped like something that already has to run in production.
The tree, and why each layer exists
The shape is a package with routers per resource, a thin services layer, and configuration and database wiring pulled out into their own modules. Nothing exotic — the point is that each file has one job and a new joiner can guess where things live.
Free · 4 minutes
Is your engineering team shipping safely, or quietly accumulating risk?
Fourteen questions on how work gets from idea to production — cadence, testing, rollback, and the key-person risk in your delivery. Banded finding on screen, full sheet by email.
myapp/
├─ pyproject.toml # managed by uv
├─ .env # local only, never committed
└─ app/
├─ __init__.py
├─ main.py # app factory + lifespan + router include
├─ config.py # Pydantic Settings
├─ db.py # engine, session factory, get_session dep
├─ dependencies.py # shared dependencies
├─ routers/
│ ├─ __init__.py
│ ├─ health.py # liveness + readiness
│ └─ orders.py # one router per resource
├─ services/
│ └─ orders.py # business logic, no HTTP concerns
└─ schemas/
└─ orders.py # Pydantic request/response models
The rule that keeps this honest: routers speak HTTP, services speak business logic, and the two never bleed. A router validates input, calls a service, and shapes the response. A service knows nothing about status codes or request objects. When you later need the same logic from a background worker or a CLI command, it is already isolated.
Configuration: Pydantic Settings, not scattered os.environ
Every reference to os.environ sprinkled through a codebase is a place configuration can be wrong at runtime with no warning. Pydantic v2’s settings support gives you one typed, validated object loaded once at startup. If a required variable is missing or malformed, the process fails to boot rather than failing on the request that happens to touch it.
# app/config.py
from functools import lru_cache
from pydantic import PostgresDsn
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
extra="ignore",
)
app_name: str = "myapp"
environment: str = "development"
database_url: PostgresDsn
request_timeout_seconds: int = 30
@lru_cache
def get_settings() -> Settings:
# Cached so the .env file is parsed once, not per request.
return Settings()
Note the package is pydantic-settings — in Pydantic 2 it was split out of the core library, so it is a separate dependency. Typing database_url as PostgresDsn means a malformed URL is caught at boot. The lru_cache wrapper turns get_settings into a dependency you can inject anywhere and override cleanly in tests.
Lifespan: the modern startup and shutdown hook
The old @app.on_event("startup") and "shutdown") decorators are deprecated. The current API is a single lifespan context manager: everything before the yield runs on startup, everything after runs on shutdown, and the resources you set up live for the life of the process. This is where you open a connection pool, warm a cache, or verify a downstream is reachable.
# app/main.py
from contextlib import asynccontextmanager
from fastapi import FastAPI
from app.config import get_settings
from app.db import engine
from app.routers import health, orders
@asynccontextmanager
async def lifespan(app: FastAPI):
# Startup: runs once, before the app accepts traffic.
settings = get_settings()
app.state.ready = True
yield
# Shutdown: runs once, on a clean stop.
app.state.ready = False
await engine.dispose()
def create_app() -> FastAPI:
settings = get_settings()
app = FastAPI(title=settings.app_name, lifespan=lifespan)
app.include_router(health.router)
app.include_router(orders.router, prefix="/orders", tags=["orders"])
return app
app = create_app()
The app factory pattern — create_app() rather than a module-level app = FastAPI() with everything hung off it — is what makes the app testable. Your test suite constructs a fresh app with overridden dependencies; production imports the pre-built app object. Both go through the same wiring.
Sessions and health, wired through dependencies
A database session should be one per request, opened and closed by the framework, injected into whatever needs it. FastAPI’s dependency system does this with a generator: the code before yield is setup, the code after is teardown, and it runs even if the handler raises.
# app/db.py
from collections.abc import AsyncGenerator
from sqlalchemy.ext.asyncio import (
AsyncSession, async_sessionmaker, create_async_engine,
)
from app.config import get_settings
engine = create_async_engine(str(get_settings().database_url))
SessionLocal = async_sessionmaker(engine, expire_on_commit=False)
async def get_session() -> AsyncGenerator[AsyncSession, None]:
async with SessionLocal() as session:
yield session
The two health endpoints are deliberately different. Liveness answers “is the process up” and must never touch a dependency — if it checked the database, a database blip would get your pods killed. Readiness answers “can I serve traffic” and is allowed to check downstreams. Orchestrators route on the distinction, so conflating them is a real outage waiting to happen.
# app/routers/health.py
from fastapi import APIRouter, Request
router = APIRouter(tags=["health"])
@router.get("/healthz")
async def liveness() -> dict[str, str]:
return {"status": "ok"}
@router.get("/readyz")
async def readiness(request: Request):
ready = getattr(request.app.state, "ready", False)
return {"ready": ready}
Running it, in dev and in production
Use uv for dependencies so the lockfile is the source of truth. Initialise, add packages, and run without ever activating a virtualenv by hand.
# set up
uv init myapp && cd myapp
uv add fastapi uvicorn[standard] pydantic-settings sqlalchemy asyncpg
# development: one worker, hot reload
uv run uvicorn app.main:app --reload
# production: multiple workers via gunicorn managing uvicorn
uv run gunicorn app.main:app \
--worker-class uvicorn.workers.UvicornWorker \
--workers 4 --bind 0.0.0.0:8000
The --reload flag is for development only; it watches the filesystem and has no place in production. Under gunicorn, each worker runs its own lifespan, so anything you open on startup opens once per worker — size your connection pool accordingly.
From here the additions slot in without moving anything: a middleware for request IDs, JWT validation as a dependency on the routers that need it, idempotency keys on the POST handlers that must be safe to retry. A pre-commit baseline keeps the layout from drifting, and once it is live the delivery metrics that actually matter tell you whether the structure is helping.
None of this is clever, and that is the point. The clever single-file app is the one you spend a fortnight untangling; the boring layered one is the one you forget you have because it never gets in the way.
Build and rescue work
Hands-on delivery of this kind is handled by Sixteen Pillars Studio.
Free interactive tool
Website compliance checklist
What your site has to do, based on what it actually does
Answer as much or as little as you like — the list builds as you go. Nothing is stored against your name and no email is required.
Everything that applies
Ordered by what to do first: legal requirements you can close quickly, then larger pieces of work, then what is expected rather than required. Not exhaustive, and not a legal audit.
Dated PDF, yours to keep or circulate.
Most technology problems are not technology problems. They are control problems.
The systems exist. The investment has been made. The question is whether leadership can understand, direct, evidence, and sustain what those systems produce. Find out where control exists — and where it only appears to.