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:
- Database → Models — defining
Base-derived dataclass models - Database → Schemas — request/response Pydantic models
- API → Endpoints — wiring routes to services
- Admin Panel → Adding Models — surfacing the model in the admin UI
This page is the meta-guide that ties them together.
Running the App¶
--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:
For tasks that declare a schedule label, run a single scheduler as well:
--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,UP039andPLC0415, which keeps imports at the top of a module known-first-party = ["src"]sosrc.*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
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:
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:
- Write the model in
models.py. Inherit fromBase, use mixins (TimestampMixin,SoftDeleteMixin,UUIDMixin) where they apply. - Write the schemas in
schemas.py. Standard set:WidgetBase,WidgetCreate,WidgetRead,WidgetUpdate, plusWidgetSelectfor FastCRUD'sschema_to_select. - Wire FastCRUD in
crud.py: - Implement the service in
service.pywith class methods that callcrud_widgets, raiseDomainErrorsubclasses on bad state. - Define routes in
routes.py. Call the service and return dicts (FastAPI serializes throughresponse_model=WidgetRead); let domain errors propagate to the global handler. - Register the router in
interfaces/main.py(or wherever your top-level routers are aggregated): - Generate a migration:
Note:
cd backend uv run --no-sync alembic revision --autogenerate -m "Add widget model" uv run --no-sync alembic upgrade headvalidate_production_migrationruns at the start ofenv.pyand refuses to apply migrations in production unlessCONFIRM_PRODUCTION_MIGRATION=yesis set. Local development is unaffected. - (Optional) Add a
WidgetAdminview — 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):
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:
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:
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_ORIGINScontaining*
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:
- Add the field to the relevant settings class (or create a new one):
- Add it to the composed
Settingsmixin list at the bottom ofsettings.py. - 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¶
- Project Structure — full layout walkthrough
- Testing — test patterns and infrastructure
- Production — deployment and hardening checklist