fastapi-patterns · git:20261101.fa88975 · 2026-11-01 · sha256 99dafcd84ac824ca

fastapi-patterns git:20261101.fa88975A

Immutable. This exact content never changes and is served at /api/v1/blob/99dafcd84ac824ca.

---
name: fastapi-patterns
description: FastAPI patterns for this template's stack — src layout with routes, services and repositories, pydantic-settings injected rather than imported, Pydantic v2 schemas, async SQLAlchemy 2.0, domain exceptions mapped to HTTP in one place, OAuth2 with PyJWT and pwdlib, and httpx + pytest-asyncio tests. Use when building or reviewing a FastAPI app — endpoints, schemas, dependencies, auth, error handling or API tests.
metadata:
  origin: ECC (adapted)
---

# FastAPI Patterns

A small but complete user API that follows `.claude/rules/` (`architecture.md`, `api-patterns.md`, `exception-handling.md`, `project-structure.md`, `security.md`, `testing.md`). Every block below is taken from `example/`, a runnable project next to this file whose tests pass on Python 3.10 and 3.12 with warnings as errors (`cd example && uv run pytest`). Read the files there for the parts summarised here.

## Dependencies

```bash
uv add fastapi pydantic-settings "pydantic[email]" "sqlalchemy[asyncio]" asyncpg pyjwt "pwdlib[argon2]" python-multipart anyio
uv add --dev pytest pytest-asyncio httpx aiosqlite
uv add --dev uvicorn  # or "fastapi[standard]" for the fastapi CLI
```

- **PyJWT and pwdlib**, not `python-jose` and `passlib`: both are unmaintained, and `passlib` breaks with current `bcrypt`. FastAPI's own docs moved to PyJWT and pwdlib with Argon2.
- `python-multipart` is required by `OAuth2PasswordRequestForm`; `anyio` runs password hashing in a worker thread. FastAPI already depends on it, but code that imports a package declares it.
- Leave version bounds to uv and **commit `uv.lock`**: the lock pins what runs, and loose bounds let uv pick per-Python versions (current `pydantic-settings` 2.16 and SQLAlchemy 2.1 need Python 3.11; with `requires-python = ">=3.10"` uv resolves older ones for 3.10). `example/uv.lock` is committed and CI runs with `--locked`.

## Project Structure

```text
src/my_app/
├── main.py                  # create_app() factory and lifespan
├── config.py                # Settings(BaseSettings)
├── database.py              # DeclarativeBase, session factory
├── exceptions.py            # AppError hierarchy (no HTTP here)
├── models/user.py           # SQLAlchemy ORM models
├── schemas/user.py          # Pydantic request/response models
├── api/
│   ├── deps.py              # settings, session, services, current user
│   ├── exceptions.py        # AppError -> HTTP status, in one place
│   └── routes/
│       ├── auth.py
│       └── users.py
├── services/user_service.py         # business rules, no FastAPI imports
└── repositories/user_repository.py  # queries, commits, IntegrityError -> ConflictError
tests/
├── conftest.py              # app + fresh database per test
├── factories.py             # test data through the public API
└── integration/test_users_api.py
```

Dependencies flow inward: routes → services → repositories. Run the app with `uv run uvicorn my_app.main:create_app --factory --reload`.

---

## App Factory

No module-level `app`, no module-level `settings`: the factory takes both, so tests build an app on their own settings and engine without patching anything.

```python
# src/my_app/main.py
"""Application factory; run with `uv run uvicorn my_app.main:create_app --factory`."""

from collections.abc import AsyncIterator
from contextlib import asynccontextmanager

from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine

from my_app.api.exceptions import register_exception_handlers
from my_app.api.routes import auth, users
from my_app.config import Settings
from my_app.database import create_session_factory


@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
    yield
    # Schema changes go through Alembic migrations, not create_all() at startup.
    await app.state.engine.dispose()


def create_app(settings: Settings | None = None, engine: AsyncEngine | None = None) -> FastAPI:
    """Build the app. Tests pass their own settings and engine instead of the environment's."""
    settings = settings or Settings()
    engine = engine or create_async_engine(settings.database_url, pool_pre_ping=True)

    app = FastAPI(title=settings.app_name, lifespan=lifespan)
    app.state.settings = settings
    app.state.engine = engine
    app.state.session_factory = create_session_factory(engine=engine)

    app.add_middleware(
        CORSMiddleware,
        allow_origins=settings.allowed_origins,
        allow_methods=["GET", "POST", "PATCH", "DELETE"],
        allow_headers=["Authorization", "Content-Type"],
    )
    register_exception_handlers(app)
    app.include_router(auth.router)
    app.include_router(users.router)
    return app
```

## Configuration

```python
# src/my_app/config.py
"""Application settings loaded from the environment."""

from pydantic import SecretStr
from pydantic_settings import BaseSettings, SettingsConfigDict


class Settings(BaseSettings):
    """Typed configuration, validated once when the app is created."""

    model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")

    app_name: str = "My App"
    database_url: str
    jwt_secret_key: SecretStr
    jwt_algorithm: str = "HS256"
    jwt_issuer: str = "my-app"
    access_token_expire_minutes: int = 30
    allowed_origins: list[str] = ["http://localhost:3000"]
```

`SecretStr` keeps the key out of logs and reprs; read it with `.get_secret_value()` where it is used. The settings object travels on `app.state` and reaches handlers through `Depends(get_settings)`, never through `from my_app.config import settings`.

## Database and Models

The model (`example/src/my_app/models/user.py`) declares `unique=True` on `email` and `username`, and `database.py` builds the session factory:

```python
# src/my_app/database.py
"""Declarative base and session factory for async SQLAlchemy."""

from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker
from sqlalchemy.orm import DeclarativeBase


class Base(DeclarativeBase):
    """Base class for every ORM model."""


def create_session_factory(engine: AsyncEngine) -> async_sessionmaker[AsyncSession]:
    """Build the session factory; attributes stay loaded after commit for async use."""
    return async_sessionmaker(bind=engine, expire_on_commit=False)
```

`expire_on_commit=False` matters under asyncio: an expired attribute would be reloaded lazily, which an async session cannot do implicitly. Create and change tables with Alembic migrations (see the `database-migrations` skill), not `create_all()` at startup.

## Schemas (Pydantic v2)

```python
# src/my_app/schemas/user.py
"""Request and response models for users and tokens."""

from datetime import datetime

from pydantic import BaseModel, ConfigDict, EmailStr, Field, field_validator, model_validator


class UserCreate(BaseModel):
    """Registration input."""

    email: EmailStr
    username: str = Field(min_length=3, max_length=50)
    password: str = Field(min_length=12)
    password_confirm: str

    @model_validator(mode="after")
    def passwords_match(self) -> "UserCreate":
        if self.password != self.password_confirm:
            raise ValueError("Passwords do not match")
        return self


class UserUpdate(BaseModel):
    """Partial update: only the fields sent are changed."""

    email: EmailStr | None = None
    username: str | None = Field(default=None, min_length=3, max_length=50)

    @field_validator("email", "username")
    @classmethod
    def reject_explicit_null(cls, value: str | None) -> str | None:
        # A field may be left out, but an explicit null would reach a NOT NULL
        # column. Defaults are not validated, so an omitted field never gets here.
        if value is None:
            raise ValueError("may be omitted, but not set to null")
        return value


class UserResponse(BaseModel):
    """Public view of a user; never carries the password hash."""

    model_config = ConfigDict(from_attributes=True)

    id: int
    email: EmailStr
    username: str
    is_active: bool
    created_at: datetime


class UserListResponse(BaseModel):
    """One page of users plus the total count."""

    total: int
    items: list[UserResponse]


class TokenResponse(BaseModel):
    """OAuth2 bearer token."""

    access_token: str
    token_type: str = "bearer"
```

Separate input (`Create`/`Update`) from output (`Response`) models, and declare `response_model` on every route, so a field such as `hashed_password` can never leak into a response. In an `Update` model, `X | None = None` lets a field be left out but also accepts an explicit `null`; the validator turns that into a 422 instead of a NOT NULL violation in the database.

## Domain Exceptions, Mapped Once

Services raise domain errors; one handler turns them into HTTP responses with a stable `code`. Routes contain no `try/except` and no status numbers.

```python
# src/my_app/exceptions.py
"""Domain exception hierarchy; HTTP status codes are assigned in api/exceptions.py."""


class AppError(Exception):
    """Base exception for the application."""

    code = "APP_ERROR"


class NotFoundError(AppError):
    """Raised when a requested resource does not exist."""

    code = "NOT_FOUND"


class ConflictError(AppError):
    """Raised when a write would break a uniqueness rule."""

    code = "CONFLICT"


class AuthenticationError(AppError):
    """Raised when credentials or a token are invalid."""

    code = "AUTHENTICATION_FAILED"


class PermissionDeniedError(AppError):
    """Raised when the caller may not perform the action."""

    code = "PERMISSION_DENIED"
```

```python
# src/my_app/api/exceptions.py
"""Map domain exceptions to HTTP responses in one place."""

from fastapi import FastAPI, Request, status
from fastapi.responses import JSONResponse

from my_app.exceptions import (
    AppError,
    AuthenticationError,
    ConflictError,
    NotFoundError,
    PermissionDeniedError,
)

STATUS_BY_ERROR: dict[type[AppError], int] = {
    NotFoundError: status.HTTP_404_NOT_FOUND,
    ConflictError: status.HTTP_409_CONFLICT,
    AuthenticationError: status.HTTP_401_UNAUTHORIZED,
    PermissionDeniedError: status.HTTP_403_FORBIDDEN,
}


async def handle_app_error(request: Request, exc: AppError) -> JSONResponse:
    """Return `{"detail", "code"}` with the status mapped from the exception type."""
    status_code = next(
        (code for error_type, code in STATUS_BY_ERROR.items() if isinstance(exc, error_type)),
        status.HTTP_400_BAD_REQUEST,
    )
    headers = {"WWW-Authenticate": "Bearer"} if isinstance(exc, AuthenticationError) else None
    return JSONResponse(
        status_code=status_code,
        content={"detail": str(exc), "code": exc.code},
        headers=headers,
    )


def register_exception_handlers(app: FastAPI) -> None:
    """Install the domain exception handler on `app`."""
    app.add_exception_handler(AppError, handle_app_error)
```

## Dependencies

Annotated aliases keep signatures short. The chain stays three levels deep: current user → service → session and settings.

```python
# src/my_app/api/deps.py
"""Shared dependencies: settings, database session, services, current user."""

from collections.abc import AsyncIterator
from typing import Annotated

from fastapi import Depends, Request
from fastapi.security import OAuth2PasswordBearer
from sqlalchemy.ext.asyncio import AsyncSession

from my_app.config import Settings
from my_app.models.user import User
from my_app.repositories.user_repository import UserRepository
from my_app.services.user_service import UserService

oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/token")


def get_settings(request: Request) -> Settings:
    """Settings the app was created with, injected rather than imported as a global."""
    return request.app.state.settings


async def get_session(request: Request) -> AsyncIterator[AsyncSession]:
    """One session per request; uncommitted work is rolled back when it closes."""
    async with request.app.state.session_factory() as session:
        yield session


SettingsDep = Annotated[Settings, Depends(get_settings)]
SessionDep = Annotated[AsyncSession, Depends(get_session)]


def get_user_service(session: SessionDep, settings: SettingsDep) -> UserService:
    return UserService(repository=UserRepository(session=session), settings=settings)


UserServiceDep = Annotated[UserService, Depends(get_user_service)]


async def get_current_user(
    token: Annotated[str, Depends(oauth2_scheme)], service: UserServiceDep
) -> User:
    return await service.user_from_token(token)


CurrentUserDep = Annotated[User, Depends(get_current_user)]
```

## Routes

```python
# src/my_app/api/routes/users.py
"""User endpoints: thin handlers that validate, delegate and serialize."""

from typing import Annotated

from fastapi import APIRouter, Query, status

from my_app.api.deps import CurrentUserDep, UserServiceDep
from my_app.models.user import User
from my_app.schemas.user import UserCreate, UserListResponse, UserResponse, UserUpdate

router = APIRouter(prefix="/users", tags=["users"])


@router.post(
    "",
    response_model=UserResponse,
    status_code=status.HTTP_201_CREATED,
    summary="Register a user",
)
async def register_user(payload: UserCreate, service: UserServiceDep) -> User:
    return await service.register(payload=payload)


@router.get("/me", response_model=UserResponse, summary="Current user")
async def read_current_user(current_user: CurrentUserDep) -> User:
    return current_user


@router.get("", response_model=UserListResponse, summary="List users")
async def list_users(
    service: UserServiceDep,
    current_user: CurrentUserDep,
    offset: Annotated[int, Query(ge=0)] = 0,
    limit: Annotated[int, Query(ge=1, le=100)] = 20,
) -> UserListResponse:
    users, total = await service.list_page(offset=offset, limit=limit)
    return UserListResponse(
        total=total, items=[UserResponse.model_validate(user) for user in users]
    )


@router.patch("/{user_id}", response_model=UserResponse, summary="Update own profile")
async def update_user(
    user_id: int, payload: UserUpdate, service: UserServiceDep, current_user: CurrentUserDep
) -> User:
    return await service.update(user_id=user_id, payload=payload, acting_user=current_user)
```

`example/src/my_app/api/routes/auth.py` adds `POST /auth/token` for the OAuth2 password flow, delegating to `UserService.authenticate()`.

## Service Layer

Business rules live here and raise domain errors. It imports nothing from FastAPI, so it can be unit tested with a fake repository. Two details worth copying: Argon2 is slow on purpose, so hashing and verifying run in a worker thread instead of blocking the event loop; and tokens must carry `exp`, `iss` and `sub`, because PyJWT checks an expiry only when one is present.

```python
# src/my_app/services/user_service.py
"""Business rules for registration, authentication and profile updates."""

from datetime import datetime, timedelta, timezone
from functools import cache

import anyio
import jwt
from pwdlib import PasswordHash

from my_app.config import Settings
from my_app.exceptions import AuthenticationError, NotFoundError, PermissionDeniedError
from my_app.models.user import User
from my_app.repositories.user_repository import UserRepository
from my_app.schemas.user import UserCreate, UserUpdate

PASSWORD_HASH = PasswordHash.recommended()
REQUIRED_TOKEN_CLAIMS = ["exp", "iss", "sub"]


@cache
def _dummy_password_hash() -> str:
    """A hash to verify when the email is unknown, so a miss takes as long as a hit."""
    return PASSWORD_HASH.hash("dummy-password-for-timing")


async def hash_password(password: str) -> str:
    """Hash off the event loop: Argon2 is deliberately slow, CPU-bound work."""
    return await anyio.to_thread.run_sync(PASSWORD_HASH.hash, password)


async def verify_password(password: str, stored_hash: str) -> bool:
    return await anyio.to_thread.run_sync(PASSWORD_HASH.verify, password, stored_hash)


class UserService:
    """User use cases; framework-agnostic and testable without HTTP."""

    def __init__(self, repository: UserRepository, settings: Settings) -> None:
        self._repository = repository
        self._settings = settings

    async def register(self, payload: UserCreate) -> User:
        user = User(
            email=payload.email,
            username=payload.username,
            hashed_password=await hash_password(payload.password),
        )
        return await self._repository.save(user)

    async def get(self, user_id: int) -> User:
        user = await self._repository.get(user_id)
        if user is None:
            raise NotFoundError(f"User {user_id} not found")
        return user

    async def list_page(self, offset: int, limit: int) -> tuple[list[User], int]:
        return await self._repository.list_page(offset=offset, limit=limit)

    async def update(self, user_id: int, payload: UserUpdate, acting_user: User) -> User:
        if acting_user.id != user_id:
            raise PermissionDeniedError("Users can only update their own profile")
        user = await self.get(user_id)
        for field_name, value in payload.model_dump(exclude_unset=True).items():
            setattr(user, field_name, value)
        return await self._repository.save(user)

    async def authenticate(self, email: str, password: str) -> str:
        """Return an access token for valid credentials."""
        user = await self._repository.get_by_email(email)
        stored_hash = user.hashed_password if user else _dummy_password_hash()
        is_valid_password = await verify_password(password, stored_hash)
        if user is None or not is_valid_password:
            raise AuthenticationError("Incorrect email or password")
        expires_at = datetime.now(timezone.utc) + timedelta(
            minutes=self._settings.access_token_expire_minutes
        )
        return jwt.encode(
            {"sub": str(user.id), "iss": self._settings.jwt_issuer, "exp": expires_at},
            self._settings.jwt_secret_key.get_secret_value(),
            algorithm=self._settings.jwt_algorithm,
        )

    async def user_from_token(self, token: str) -> User:
        """Resolve a bearer token to an active user.

        The token must carry an expiry and this app's issuer: PyJWT checks `exp`
        only when present, so without `require` a token minted without one,
        or by another service sharing the secret, would be accepted forever.
        """
        try:
            claims = jwt.decode(
                token,
                self._settings.jwt_secret_key.get_secret_value(),
                algorithms=[self._settings.jwt_algorithm],
                issuer=self._settings.jwt_issuer,
                options={"require": REQUIRED_TOKEN_CLAIMS},
            )
            user_id = int(claims["sub"])
        except (jwt.InvalidTokenError, ValueError) as err:
            raise AuthenticationError("Could not validate credentials") from err
        user = await self._repository.get(user_id)
        if user is None:
            raise AuthenticationError("Could not validate credentials")
        if not user.is_active:
            raise PermissionDeniedError("Inactive user")
        return user
```

## Repository

Owns the queries and the commit, and turns the database's uniqueness violation into a domain `ConflictError`. Rely on the unique constraint rather than a "does it exist?" precheck, which races under concurrent requests.

```python
# src/my_app/repositories/user_repository.py
"""Data access for users."""

from sqlalchemy import func, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession

from my_app.exceptions import ConflictError
from my_app.models.user import User


class UserRepository:
    """Queries and writes for `User`; translates database errors into domain errors."""

    def __init__(self, session: AsyncSession) -> None:
        self._session = session

    async def get(self, user_id: int) -> User | None:
        return await self._session.get(User, user_id)

    async def get_by_email(self, email: str) -> User | None:
        result = await self._session.execute(select(User).where(User.email == email))
        return result.scalar_one_or_none()

    async def list_page(self, offset: int, limit: int) -> tuple[list[User], int]:
        total = await self._session.scalar(select(func.count()).select_from(User))
        # A stable order keeps offset pagination from skipping or repeating rows.
        result = await self._session.scalars(
            select(User).order_by(User.id).offset(offset).limit(limit)
        )
        return list(result), total or 0

    async def save(self, user: User) -> User:
        """Insert or update `user` and commit; a uniqueness violation becomes ConflictError."""
        self._session.add(user)
        try:
            await self._session.commit()
        except IntegrityError as err:
            await self._session.rollback()
            raise ConflictError("A user with this email or username already exists") from err
        await self._session.refresh(user)
        return user
```

---

## Testing with httpx and pytest-asyncio

```toml
# pyproject.toml
[tool.pytest.ini_options]
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"
markers = ["integration: marks tests requiring external services"]
addopts = "--strict-markers"
```

```python
# tests/conftest.py
"""Fixtures: an app on a fresh in-memory database for every test."""

from collections.abc import AsyncIterator

from httpx import ASGITransport, AsyncClient
import pytest
from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine

from my_app.config import Settings
from my_app.database import Base
from my_app.main import create_app

TEST_DATABASE_URL = "sqlite+aiosqlite://"


@pytest.fixture
def settings() -> Settings:
    return Settings(
        database_url=TEST_DATABASE_URL,
        jwt_secret_key="test-only-secret-key-of-at-least-32-bytes",
    )


@pytest.fixture
async def engine() -> AsyncIterator[AsyncEngine]:
    # One engine per test, so each test starts on an empty database.
    engine = create_async_engine(TEST_DATABASE_URL)
    async with engine.begin() as connection:
        await connection.run_sync(Base.metadata.create_all)
    yield engine
    await engine.dispose()


@pytest.fixture
async def client(settings: Settings, engine: AsyncEngine) -> AsyncIterator[AsyncClient]:
    app = create_app(settings=settings, engine=engine)
    async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
        yield client
```

`example/tests/factories.py` registers users and logs in through the API, and `example/tests/integration/test_users_api.py` covers registration, the 409 conflict, the 422 validation error, the 401 challenge, the 403 on someone else's profile and stable pagination; `example/tests/integration/test_jwt_claims.py` rejects tokens without an expiry or with a foreign issuer, and an explicit `null` in an update. A typical test:

```python
async def test_update_user_other_account_returns_403(client: AsyncClient) -> None:
    await register_user(client=client, email="ada@example.com", username="ada")
    other = await register_user(client=client, email="bob@example.com", username="bob")
    headers = await auth_headers(client=client, email="ada@example.com")

    response = await client.patch(
        f"/users/{other['id']}", json={"username": "taken"}, headers=headers
    )

    assert response.status_code == 403
    assert response.json()["code"] == "PERMISSION_DENIED"
```

`ASGITransport` does not run the app's lifespan; that is why the engine is injected into `create_app()` rather than created in the lifespan. SQLite in memory keeps these tests fast; run the same suite against PostgreSQL (for example a container in CI) before trusting constraint and type behaviour that SQLite does not share.

---

## Anti-Patterns

```python
# Bad: business logic and HTTP translation inside the handler.
@router.post("/users")
async def create_user(payload: UserCreate, session: SessionDep):
    if await session.scalar(select(User).where(User.email == payload.email)):  # races
        raise HTTPException(status_code=400, detail="Email taken")  # magic number
    ...

# Good: thin handler; the repository turns IntegrityError into ConflictError,
# and api/exceptions.py maps it to 409 for every endpoint at once.
@router.post("/users", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
async def register_user(payload: UserCreate, service: UserServiceDep) -> User:
    return await service.register(payload=payload)


# Bad: settings imported as a global, so tests must patch the module.
from my_app.config import settings

# Good: injected.
def get_settings(request: Request) -> Settings:
    return request.app.state.settings


# Bad: sync session in an async route blocks the event loop.
@router.get("/items")
async def list_items(db: Session = Depends(get_sync_db)):
    return db.query(Item).all()
```

## Checklist

- [ ] Every route declares `response_model` and uses `status.HTTP_*` constants
- [ ] Routes call one service method; no `try/except`, no queries
- [ ] Domain errors inherit from `AppError` and are mapped in `api/exceptions.py`
- [ ] Settings and sessions arrive through `Depends()`, never module globals
- [ ] Paginated queries have an explicit `order_by`
- [ ] Unknown email and wrong password take the same time and return the same 401
- [ ] Tokens require `exp`, `iss` and `sub`; the issuer is checked on decode
- [ ] CPU-bound work (password hashing) runs off the event loop
- [ ] `uv.lock` is committed
- [ ] Schema changes are Alembic migrations