Skip to content

Development Guide

This page covers the day-to-day development loop: running the app, the tools that ship with it, how to add a new module, and what to know about debugging the boilerplate's moving parts.

For end-to-end "how do I add an entity," see:

This page is the meta-guide that ties them together.

Running the App

cd backend
uv run --no-sync fastapi dev src/interfaces/main.py

--reload watches the filesystem and restarts on Python file changes. Use it for development; never in production.

If you're using Docker, from the repository root:

docker compose up -d                # API + Postgres + Redis
docker compose logs -f api          # tail the api logs
docker compose exec api bash        # shell into the api container

The service names depend on your docker-compose.yml; the api name is conventional.

The Background Worker

If your app uses Taskiq tasks, run a worker alongside the API in a second terminal:

cd backend
uv run --no-sync taskiq worker src.infrastructure.taskiq.worker:default_broker --reload

For tasks that declare a schedule label, run a single scheduler as well:

cd backend
uv run --no-sync taskiq scheduler src.infrastructure.taskiq.scheduler:scheduler

--reload needs taskiq[reload], which ships in the dev extra. A workspace synced with --all-extras already has it; --extra dev is what installs it into an environment that was synced without it. Drop both in production. See Background Tasks for details.

The Dev Toolchain

The project ships configured ruff, mypy, and pytest via backend/pyproject.toml:

cd backend

# Lint + format (ruff handles both)
uv run --no-sync ruff check .
uv run --no-sync ruff format .
uv run --no-sync ruff check --fix .          # auto-fix what ruff can

# Type check
uv run --no-sync mypy src scripts migrations tests

# Tests
uv run --no-sync pytest
uv run --no-sync pytest -k "test_user"       # run tests matching a name
uv run --no-sync pytest -x                   # stop on first failure
uv run --no-sync pytest -n auto              # parallel via pytest-xdist

Ruff is configured (pyproject.toml:[tool.ruff], and backend/pyproject.toml for the backend) with:

  • line-length = 128
  • Selected rule sets: E, F, I, UP (pycodestyle, pyflakes, isort, pyupgrade)
  • Extended in the backend with UP006, UP007, UP035, UP039 and PLC0415, which keeps imports at the top of a module
  • known-first-party = ["src"] so src.* imports are grouped correctly

Mypy is intentionally relaxed about annotations (disallow_untyped_defs = false) — adopt strictness gradually as you add types to new modules. It does run over src, scripts, migrations and tests, in CI and in the pre-commit hook, and it loads the pydantic plugin (plugins = ["pydantic.mypy"]), which reads a schema's required fields. The plugin only sees a default that sits on the assignment side, so write

name: Annotated[str | None, Field(max_length=50)] = None

rather than putting default=None inside Field(...); otherwise every construction without that field reads as an error.

Pre-Commit

The repo's .pre-commit-config.yaml runs what CI runs, and nothing that fights it:

Hook What it does
ruff-check --fix, ruff-format the same lint and format CI checks
pyupgrade rewrites to Python 3.11+ syntax
yesqa removes a noqa nothing needs any more
hygiene hooks final newlines, trailing whitespace, YAML, large files, private keys, merge markers
mypy_backend, mypy_cli local hooks, the same invocations CI uses
unit_test local, manual stage: pytest tests/unit

unit_test runs the unit tests only — they need no container and finish in seconds. The integration suite, which starts Postgres through testcontainers, runs in CI and when you run pytest yourself.

There are no docstring- or markdown-formatting hooks: ruff-format owns formatting, and the two documentation hooks this repo used to carry damaged what they touched — mdformat broke MkDocs content tabs, and blacken-docs rewrote code samples into different code.

check-docstring-first is left out for the same reason. This project documents a module-level name with an attribute docstring under it, which the hook reads as a second module docstring, so it refused 40 files.

pre-commit ships in the dev extra, so uv sync --all-packages --all-extras installs it. Install the git hook once:

uv run --no-sync pre-commit install

After that, git commit runs the hooks automatically. To run them ad hoc:

uv run --no-sync pre-commit run --all-files
uv run --no-sync pre-commit run unit_test --hook-stage manual --all-files

Adding a New Module

The boilerplate organizes domain code under backend/src/modules/<name>/ with a vertical-slice layout. To add a widgets module:

backend/src/modules/widgets/
├── __init__.py
├── models.py        # SQLAlchemy model (Base + dataclass)
├── schemas.py       # Pydantic request/response models
├── crud.py          # FastCRUD instance
├── service.py       # Business logic — calls CRUD, raises domain errors
└── routes.py        # FastAPI router — wraps the service, handles HTTP

The full pattern (with concrete code) is in Database → Models and API → Endpoints. The short version:

  1. Write the model in models.py. Inherit from Base, use mixins (TimestampMixin, SoftDeleteMixin, UUIDMixin) where they apply.
  2. Write the schemas in schemas.py. Standard set: WidgetBase, WidgetCreate, WidgetRead, WidgetUpdate, plus WidgetSelect for FastCRUD's schema_to_select.
  3. Wire FastCRUD in crud.py:
    from fastcrud import FastCRUD
    from .models import Widget
    crud_widgets = FastCRUD(Widget)
    
  4. Implement the service in service.py with class methods that call crud_widgets, raise DomainError subclasses on bad state.
  5. Define routes in routes.py. Call the service and return dicts (FastAPI serializes through response_model=WidgetRead); let domain errors propagate to the global handler.
  6. Register the router in interfaces/main.py (or wherever your top-level routers are aggregated):
    from src.modules.widgets.routes import router as widgets_router
    api_v1.include_router(widgets_router, prefix="/widgets")
    
  7. Generate a migration:
    cd backend
    uv run --no-sync alembic revision --autogenerate -m "Add widget model"
    uv run --no-sync alembic upgrade head
    
    Note: validate_production_migration runs at the start of env.py and refuses to apply migrations in production unless CONFIRM_PRODUCTION_MIGRATION=yes is set. Local development is unaffected.
  8. (Optional) Add a WidgetAdmin view — see Admin Panel → Adding Models.

The Alembic env (backend/migrations/env.py) auto-discovers models via import_models("src.modules"), so new modules are picked up by --autogenerate without any manual import wiring — provided your model is in modules/<name>/models.py.

Adding Custom Middleware

Middleware lives at backend/src/infrastructure/middleware.py (or a peer file you create). The pattern:

import time

from fastapi import Request, Response
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
from starlette.types import ASGIApp


class TimingMiddleware(BaseHTTPMiddleware):
    async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
        start = time.perf_counter()
        response = await call_next(request)
        elapsed_ms = (time.perf_counter() - start) * 1000
        response.headers["X-Process-Time-Ms"] = f"{elapsed_ms:.1f}"
        return response

Register in infrastructure/app_factory.py (or your overridden create_application):

application.add_middleware(TimingMiddleware)

Order matters — middleware added later runs earlier in the request path. The boilerplate's own middlewares (SecurityHeadersMiddleware, ClientCacheMiddleware, CatchAllErrorMiddleware, GZipMiddleware, CORSMiddleware) are added in a deliberate order; see app_factory.py:create_application. The admin panel's SessionMiddleware isn't among them: it is mounted on the panel's routes only.

Adding a Custom Dependency

Dependencies belong with the feature they serve. For session-aware dependencies, look at infrastructure/auth/dependencies.py:get_current_user for a template.

Define the factory

# modules/workspace/dependencies.py
from fastapi import Request

from ...infrastructure.auth.dependencies import get_current_user
from ...infrastructure.auth.deps import CurrentUserDep


def get_workspace(
    request: Request,
    current_user: CurrentUserDep,
) -> str:
    workspace = request.headers.get("X-Workspace")
    if not workspace:
        raise PermissionDeniedError("Missing workspace header")
    # validate workspace membership against current_user...
    return workspace

Register the alias (optional)

Add it to the module's dependencies.py so routes can use it without typing Depends(get_workspace) every time:

# modules/workspace/dependencies.py (extended)
from typing import Annotated

from fastapi import Depends

from ...infrastructure.auth.deps import CurrentUserDep

WorkspaceDep = Annotated[str, Depends(get_workspace)]

Use it

from ..dependencies import WorkspaceDep


@router.get("/workspace/items")
async def list_workspace_items(
    workspace: WorkspaceDep,
    db: AsyncSessionDep,
) -> list[dict[str, Any]]:
    ...

Per-module service aliases follow the same pattern — see the existing modules/{user,tier,rate_limit,api_keys}/dependencies.py files for real examples.

Debugging Tips

See every SQL query

There is no setting for this: the engine is built with echo=False and hide_parameters=True (infrastructure/database/session.py). Raise SQLAlchemy's own logger to see the statements:

logging.getLogger("sqlalchemy.engine").setLevel(logging.INFO)

Statements are logged without their parameter values, which is deliberate — a failing insert must not write a password hash to the log. For a one-off look at the values, build an engine of your own with build_engine(hide_parameters=False, echo=True).

Inspect rate-limit and cache state

docker compose exec redis redis-cli
> SELECT 0                           # cache DB
> KEYS '*'
> SELECT 1                           # rate-limiter DB
> KEYS 'ratelimit:*'
> SELECT 3                           # taskiq queue DB
> LRANGE default 0 -1                # pending tasks

Each subsystem uses a different Redis DB number; see .env.example for the conventions (CACHE_REDIS_DB=0, RATE_LIMITER_REDIS_DB=1, SESSION_REDIS_DB=2, TASKIQ_REDIS_DB=3).

Watch sessions live

Session storage is managed by the crudauth library, so there's no boilerplate SessionManager to call. If a user reports being logged out unexpectedly, inspect the session store directly in Redis:

redis-cli
> SELECT 2                           # session backend DB (SESSION_REDIS_DB)
> KEYS 'session:*'

See Authentication → Sessions for full details.

Use the interactive docs

http://localhost:8000/docs (Swagger UI) and http://localhost:8000/redoc are auto-generated from your routes. Send requests directly from the UI; every endpoint that takes a Pydantic body has a "Try it out" form. Only present in non-production by default — gated by OPENAPI_URL.

Production validators

When ENVIRONMENT=production, infrastructure/security/ runs validators at startup that fail loudly on:

  • Insecure or placeholder SECRET_KEY
  • Default or empty database password
  • Admin panel enabled without ADMIN_USERNAME/ADMIN_PASSWORD
  • CORS_ORIGINS containing *

An empty CORS_ORIGINS isn't an error: it means no cross-origin request is allowed at all.

If your prod boot is failing with one of those, that's your hint — don't bypass the validator.

Testing

The repo ships a suite in backend/tests/, and backend/pyproject.toml configures pytest with:

[tool.pytest.ini_options]
testpaths = ["tests"]
asyncio_mode = "auto"
addopts = ["-v", "--strict-markers", "--tb=short"]

Nothing forces an ENVIRONMENT on the suite. Leave it at local or development: the production validator runs its checks only in production, so the tests aren't blocked by missing prod-only env vars.

A sane starting tests/conftest.py:

# tests/conftest.py
import pytest
import pytest_asyncio
from httpx import ASGITransport, AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine

from src.infrastructure.database.session import Base
from src.infrastructure.database.session import async_session
from src.interfaces.main import app

TEST_DATABASE_URL = "postgresql+asyncpg://postgres:postgres@localhost:5432/test_db"


@pytest_asyncio.fixture
async def db_engine():
    engine = create_async_engine(TEST_DATABASE_URL)
    async with engine.begin() as conn:
        await conn.run_sync(Base.metadata.create_all)
    yield engine
    async with engine.begin() as conn:
        await conn.run_sync(Base.metadata.drop_all)
    await engine.dispose()


@pytest_asyncio.fixture
async def db_session(db_engine) -> AsyncSession:
    factory = async_sessionmaker(db_engine, expire_on_commit=False)
    async with factory() as session:
        yield session


@pytest_asyncio.fixture
async def client(db_session):
    async def override_db():
        yield db_session

    app.dependency_overrides[async_session] = override_db
    transport = ASGITransport(app=app)
    async with AsyncClient(transport=transport, base_url="http://test") as ac:
        yield ac
    app.dependency_overrides.clear()

Then a smoke test:

# tests/test_smoke.py
async def test_health(client):
    response = await client.get("/health")
    assert response.status_code == 200

For tests that genuinely need Postgres semantics (FK constraints, ARRAY types, JSONB), testcontainers[postgres] is already a dev dependency — spin up a real Postgres in a fixture instead of mocking the database.

For unit tests on services, mock at the CRUD layer, not at the database. The service contract is "I call crud_widgets.get and get back a dict-or-None"; that's the seam to mock.

Customizing the Settings

Settings live in backend/src/infrastructure/config/settings.py. To add a new env-driven value:

  1. Add the field to the relevant settings class (or create a new one):
    class WidgetSettings(BaseSettings):
        WIDGET_BATCH_SIZE: int = config("WIDGET_BATCH_SIZE", default=100, cast=int)
    
  2. Add it to the composed Settings mixin list at the bottom of settings.py.
  3. Document the env var in backend/.env.example.

Then read it via get_settings().WIDGET_BATCH_SIZE.

See Configuration → Settings Classes for the full pattern.

Disabling Subsystems

Most major subsystems toggle via env vars rather than code changes:

Subsystem Toggle Effect
Cache CACHE_ENABLED=false @cache becomes a no-op
Client cache CLIENT_CACHE_ENABLED=false Middleware doesn't mount
Rate limiter RATE_LIMITER_ENABLED=false crudauth API limiter returns immediately
Background tasks Don't run the worker The broker is created but no consumer
Admin panel ADMIN_ENABLED=false /admin is unmounted
Documentation OPENAPI_URL= Disables /docs and /redoc

Removing a subsystem entirely (deleting the code) is rare and usually wrong — leaving it disabled costs nothing.

Common Mistakes

"Auto-import" gotchas

The boilerplate uses import_models("src.modules") in Alembic to discover models. The discovery walks modules/<name>/models.py only. If you put models in modules/<name>/sub/inner.py, autogenerate won't find them. Either keep models in models.py or hand-import the file.

Forgetting lazy="selectin" on a relationship

SQLAdmin runs in async context. A relationship without lazy="selectin" raises MissingGreenlet when the admin tries to render it. User.tier and other relationships in the boilerplate already use this pattern — copy from those. (Tier.users is a deliberate exception: it uses lazy="select" so loading a tier doesn't load every user in it.)

Dataclass models without init=False on relationships

Base = DeclarativeBase + MappedAsDataclass. Relationship fields must use init=False or they end up in the dataclass __init__ and crash on insert. See modules/user/models.py:User.tier for the pattern.

Catching exceptions too broadly in routes

Services raise domain errors (ResourceNotFoundError, PermissionDeniedError, …); routes don't translate them. register_exception_handlers(app) in modules/common/utils/error_handler.py installs one handler that maps any DomainError to its status through EXCEPTION_MAPPING, and a catch-all that turns anything else into a 500 with a support id. A route catches a domain error only when it wants to answer something else.

Cache decorators without request: Request

The @cache decorator inspects request.method to decide read vs invalidate. The first parameter of every decorated route must be request: Request. See Caching → Server-Side Cache for the rest of the contract.

Key Files

Component Location
App factory / middleware order backend/src/infrastructure/app_factory.py
Settings backend/src/infrastructure/config/settings.py
Lifespan / startup backend/src/infrastructure/app_factory.py:lifespan_factory
Database session backend/src/infrastructure/database/session.py
Module template (reference) backend/src/modules/user/
Pre-commit .pre-commit-config.yaml
pyproject (lint / type / test) backend/pyproject.toml

Next Steps