This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Python 3.14 async REST API. FastAPI + SQLAlchemy 2 (async) + PostgreSQL + Alembic. Served by Granian (uvloop). Dependencies managed by uv. Linted with ruff, type-checked with ty (not mypy). Observability via lite-bootstrap (OpenTelemetry, Sentry). DI via modern-di. Repositories via advanced-alchemy.
The development workflow runs inside Docker via just (just --list / read the justfile for the full recipe list). The application service mounts the repo and depends on a db Postgres service. Bare just runs the default pipeline (install + lint + build + test).
Things the recipe names don't make obvious:
just test [args]recreates a clean DB first (alembic downgrade base→upgrade head) and tears the stack down afterward;[args]are passed through topytest.just test-migrations [args]runs thepytest-alembicsuite intests/migrations/against an empty database: single head, upgrade, up/down consistency for every revision, and models matching the migrated schema (alembic checkas a test).just testskips that directory (--ignoreinaddopts,omitin coverage) because it migrates the schema under the transaction-rollback fixture, so the recipe clearsaddopts.just runapplies migrations then starts the app on :8000.just lintauto-fixes (eof-fixer +ruff format+ruff check --fix+ty check); CI (below) is the no-fix gate.
Running a single test (inside container or with DB available):
uv run pytest tests/test_decks.py::test_get_decks -xCI (.github/workflows/main.yml) runs ruff format --check, ruff check --no-fix, ty check, then two Postgres jobs: alembic upgrade head followed by pytest, and the pytest-alembic suite. Match this locally before pushing.
app/__main__.py boots Granian pointing at app.application:build_app (factory). build_app() in app/application.py:
- Creates a
modern_di.Containerwithioc.ALL_GROUPSfromapp/ioc.py. - Builds a
FastAPIBootstrapperfromsettings.api_bootstrapper_config, injecting the SQLAlchemy OpenTelemetry instrumentor. modern_di_fastapi.setup_di(app, container)wires DI scopes onto the FastAPI app.- Includes one
ROUTERper resource (app.api.decks.ROUTER,app.api.cards.ROUTER) under/apiviainclude_routers. Add a new resource by creatingapp/api/<name>.pywith its ownAPIRouter-basedROUTERand registering it there. - Registers handlers from
app/exceptions.py:DuplicateKeyError→ 422,NotFoundErrorandForeignKeyError→ 404, andsqlalchemy.exc.TimeoutErrorandRepositoryError→database_unavailable_handler, which returns 503 for a pool timeout and re-raises anything else.
app/ioc.py splits providers into three groups, collected in ALL_GROUPS.
Database:
database_engine: singleton-ish primaryAsyncEngine(create_primary_sa_engine,close_sa_enginefinalizer).database_read_engine: singleton-ish readAsyncEnginebuilt fromDB_REPLICA_DSN, or fromDB_DSNwhen it is unset. It runs inAUTOCOMMIT(no BEGIN/ROLLBACK per request) and setsdefault_transaction_read_only=on, so Postgres rejects writes on it. Each statement sees its own snapshot, so a read that runs several queries can see a write that commits in between. Load related rows in the same query (fetch_with_cardsusesjoinedload). Without a replica it is a second pool on the primary, so a process can hold up to2 × db_pool_sizeconnections.dynamic_engine:Scope.REQUEST;choose_sa_enginereturns the read engine forGET/HEADrequests, the primary otherwise (including when there is no request). GET handlers cannot write, and a read right after a write may see replica lag.session:Scope.REQUEST, bound todynamic_engine, finalized byclose_session.transaction:Scope.REQUEST, adb_retry.Transactionoversession. Leaving its block rolls back anything uncommitted and closes the session.autocommit_transaction: the sameTransaction, opened atAUTOCOMMIT(autocommit_isolation_level).CreateDeckUseCaseandUpdateDeckUseCaseuse it because each sends exactly one statement, which Postgres runs atomically on its own. The asyncpg adapter then sends no BEGIN or COMMIT, so those writes cost one round trip instead of three. A use case with two or more statements, or one that relies on rolling back after a check, asUpdateCardsUseCasedoes, keepstransaction. Thedb_sessionfixture overridesautocommit_isolation_leveltoNone, because switching the shared test connection toAUTOCOMMITwould roll back its outer transaction.tests/test_autocommit_writes.pyruns both use cases against a real engine instead, through thecommitting_enginefixture.tests/test_card_locking.pyuses the same fixture, because a lock wait needs separate connections; both delete the rows they commit.
Repositories (Scope.REQUEST): decks_repository and cards_repository, on Database.session with auto_commit=False. The use case commits through transaction.commit().
UseCases (Scope.REQUEST): one provider per use case in app/use_cases/. modern-di fills each dataclass field by its type, so a use case needs no kwargs.
Endpoints inject a use case with FromDI(UseCase) from modern_di_fastapi, call it, and convert the result to a schema. They never touch repositories or the session. Add new providers to the matching group rather than constructing services manually in routes.
DB_POOL_SIZE (positive) and DB_MAX_OVERFLOW (non-negative) size the pool, and app/__main__.py sets Granian's backpressure to their sum, so each worker accepts only as many requests at once as it has connections. Requests beyond that wait in Granian's socket backlog instead of queueing on the pool. APP_WORKERS sets the Granian worker count, and each worker has its own pool. DB_POOL_TIMEOUT (default 30 s) bounds the wait for a connection, and a timeout returns 503 through exceptions.database_unavailable_handler. advanced-alchemy wraps the timeout in a RepositoryError, so the handler is also registered for RepositoryError and re-raises anything not caused by a pool timeout. DB_POOL_RECYCLE defaults to SQLAlchemy's -1 (never). Set it below the idle timeout of any proxy or load balancer in front of Postgres, so a connection it dropped is not handed out and retried after a 1-2 s backoff.
pool_pre_ping is off by default (DB_POOL_PRE_PING). A pooled connection that died while idle is handled by db_retry.postgres_retry instead, which saves a round trip on every checkout. That only works if the retried function owns its whole transaction, so every use case has this shape:
@dataclasses.dataclass(kw_only=True, frozen=True, slots=True)
class ListDecksUseCase:
transaction: Transaction
decks_repository: DecksRepository
@postgres_retry
async def __call__(self, *, after: int | None, limit: int) -> Sequence[models.Deck]:
async with self.transaction:
decks: typing.Final = await self.decks_repository.list_decks(after=after, limit=limit)
await self.transaction.commit()
return decks- Every query runs inside
async with self.transaction:. A query before the block leaves the session in a failed transaction on a lost connection, and the retry then fails withPendingRollbackError. - Commit before leaving the block, reads included, and before any early return. Otherwise
__aexit__rolls back, which expires the loaded rows. - A retry runs the whole use case again. If the connection drops during
COMMIT, the write may already have landed. A write that creates rows needs something that makes the second run harmless:CreateDeckUseCasetakes a client-generatedidempotency_key(schemas.DeckCreate, unique ondecks).DecksRepository.get_or_createsends oneINSERT ... ON CONFLICT (idempotency_key) DO UPDATE SET idempotency_key = EXCLUDED.idempotency_key RETURNING *. On a conflict the no-op update leaves the row as it was andRETURNINGhands it back (DO NOTHINGwould return no row), so a retry or a client resend gets the first deck. A concurrent request with the same key waits on the row lock and then gets the same deck. advanced-alchemy's ownupsertandget_or_upsertdo a SELECT followed by an INSERT, so they cost two round trips and lose that race.CreateCardsUseCaserelies on the(deck_id, front)unique constraint. A replay is rejected as a duplicate-key error rather than creating the cards twice.- Updates (
UpdateDeckUseCase,UpdateCardsUseCase) write the same values again, so a replay is harmless.
app/use_cases/ is exempt from ruff's TC rules because modern-di reads the dataclass field annotations at runtime.
app/models.py:BigIntAuditBasefromadvanced_alchemy(autoid,created_at,updated_at). The module aliasesorm_registry.metadataontoorm.DeclarativeBase.metadataso Alembic autogenerate sees both. New models go here.app/repositories.py: SubclassSQLAlchemyAsyncRepositoryService[Model]with a nestedBaseRepository(SQLAlchemyAsyncRepository[Model]). Use cases call the service methods (get_many,get_one,create_many) for reads and plain inserts. Writes that advanced-alchemy would spread over several round trips are one statement on the session instead: itsupdatereads the row twice and refreshes it after the flush, andupsert_manyselects the rows before and after writing them.update_decksendsUPDATE ... RETURNINGandget_or_createsendsINSERT ... ON CONFLICT ... RETURNING.update_cardssends oneWITH locked AS (SELECT id FROM cards WHERE id = ANY(:ids) AND deck_id = :deck_id ORDER BY id FOR UPDATE) UPDATE cards ... FROM unnest(:ids, :fronts, :backs, :hints) AS updates(...), locked WHERE cards.id = updates.id AND cards.id = locked.id AND cards.deck_id = :deck_id RETURNING *. It binds one array per column, so the SQL text stays the same for any number of cards: SQLAlchemy caches it and asyncpg prepares it once per connection, which a multi-rowVALUESwould not allow. Every updated row must match alockedrow, and the CTE locks its rows in id order (lazily, one before each update), so concurrent PUTs on overlapping cards always lock them in the same order and cannot deadlock. Without it the lock order follows the plan, which can be the order the client sent;tests/test_card_locking.pyforces that interleaving.card_deck_id_front_ucisDEFERRABLE INITIALLY IMMEDIATE, so uniqueness is checked at the end of the statement and one PUT can swap two cards' fronts. It raisesNotFoundError(404) if fewer rows come back than ids were sent, which rolls the whole request back. PUT never inserts. AnINSERT ... ON CONFLICT (id)upsert would move a card in from another deck and would insert client-chosen ids without advancingcards_id_seq, so a later POST would collide on the primary key.schemas.CardCreatesandschemas.CardUpdatescap a request atMAX_CARDS_PER_REQUESTcards, andschemas.CardUpdatesrejects duplicate ids, both with a 422 before any query runs. Ids in paths,afterand card bodies are bounded to Postgresbigint(schemas.MAX_ID), so an oversized id is a 422 and never reaches asyncpg. A POST to a deck that does not exist fails on the foreign key, andForeignKeyErrormaps to 404. A statement that can hit a unique constraint runs insidewrap_sqlalchemy_exceptionso that it still raisesDuplicateKeyError.app/resources/db.py: engine factories live here and are DI creators: modern-di resolves their parameter annotations at runtime, sopyproject.tomlexemptsapp/resources/from ruff'sTCrules.create_sessionpassesjoin_transaction_mode="create_savepoint". This is inert in production (the session binds to an engine) but enables the test rollback pattern below: when a test binds the session to a connection already in a transaction, the session owns its own savepoint so the outer transaction survives commits. Do not "fix" it.migrations/env.pyswaps the asyncpg driver for the syncpostgresqldriver and usesapp.models.METADATAastarget_metadata.
app/settings.py: pydantic_settings.BaseSettings. Env vars are unprefixed (DB_DSN, DB_REPLICA_DSN, SERVICE_DEBUG, SERVICE_ENVIRONMENT, LOG_LEVEL, APP_HOST, APP_PORT, OPENTELEMETRY_ENDPOINT, SENTRY_DSN, CORS_ALLOWED_ORIGINS, APP_WORKERS, DB_POOL_TIMEOUT, DB_POOL_RECYCLE, ...). api_bootstrapper_config produces a FastAPIConfig for lite-bootstrap. LOG_LEVEL is case-insensitive and sets both Granian's log level and the app's root logger.
tests/conftest.py provides the test isolation pattern. Read it before adding fixtures:
appfixture builds a fresh app viaLifespanManager.db_sessionopens a connection, begins a transaction, and overridesDatabase.dynamic_enginewith the connection itself, so reads and writes share one rolled-back transaction. Each session built against that connection usesjoin_transaction_mode="create_savepoint", sotransaction.commit()releases the session's own savepoint while the outer transaction is rolled back at teardown, so each test starts clean.set_async_session_in_base_sqlalchemy_factorywiresdb_sessionintoSQLAlchemyFactory.__async_session__sopolyfactoryfactories intests/factories.py(DeckModelFactory,CardModelFactory) persist via the rolled-back session. Test modules that use these factories opt in withpytestmark = [pytest.mark.usefixtures("set_async_session_in_base_sqlalchemy_factory")].
pytest.ini_options sets asyncio_mode = "auto", so async tests do not need @pytest.mark.asyncio. Coverage runs by default with --cov-fail-under=100, so just test fails unless tests cover every line of new code.
- Ruff is configured with
select = ["ALL"]and a curated ignore list inpyproject.toml. Don't sprinkle# noqa; prefer fixing or extending the project ignore list if a rule is genuinely wrong for the codebase. - Routes convert ORM objects to schemas explicitly, never via
typing.cast. Single objects:schemas.X.model_validate(instance). Collections:schemas.Xs.from_models(objects)(theCollection[T]seam inapp/schemas.py). Both rely onfrom_attributes=True. - List endpoints page by id (keyset). They take
limit(1 toschemas.MAX_PAGE_LIMIT, defaultschemas.DEFAULT_PAGE_LIMIT) andafter, the last id the client has seen, and return aPage[T]subclass (schemas.DecksPage,schemas.CardsPage) withitemsandnext_after. The repository filtersid > afterand fetcheslimit + 1rows ordered by id.Page.from_models(rows, limit=limit)drops the extra row and setsnext_afterto the last returned id when that row existed, ornullon the last page. Don't page with OFFSET: Postgres reads and discards every skipped row. - Deck responses are deliberately two-shaped:
list_decks/create_deck/update_deckreturn the lightschemas.Deck(nocards), whileget_deckreturnsschemas.DeckWithCards. The split mirrors loading (lists and writes usenoload, with no cards query; detail usesjoinedloadviafetch_with_cards), so the type states exactly what each endpoint loads. - Line length is 120.
GitHub issues on modern-python/fastapi-sqlalchemy-template, via gh. See docs/agents/issue-tracker.md.
The five canonical roles, each label string equal to its name. See docs/agents/triage-labels.md.
Single-context: CONTEXT.md and docs/adr/ at the repo root. See docs/agents/domain.md.