22
33This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
44
5- ` chat-app ` was bootstrapped from ` litestar-sqlalchemy-template ` . It is ** not **
6- the template with new routes — three things below differ from what the
7- template teaches, and getting any of them wrong by pattern-matching on the
8- template breaks the transaction model or the DI wiring.
5+ ` chat-app ` was bootstrapped from ` litestar-sqlalchemy-template ` , but three
6+ things below differ from what the template teaches. Getting any of them wrong
7+ by pattern-matching on the template breaks the transaction model or the DI
8+ wiring.
99
1010## The three things most likely to be got wrong
1111
@@ -16,13 +16,13 @@ template breaks the transaction model or the DI wiring.
1616 example.
17172 . ** Repositories run ` auto_commit=False ` .** Every ` *_repository ` provider in
1818 ` app/ioc.py ` is constructed with `kwargs={"session": ..., "auto_commit":
19- False}` — the opposite of the template's ` auto_commit=True`. A repository
19+ False}` , the opposite of the template's ` auto_commit=True`. A repository
2020 here never commits on its own.
21213 . ** Use cases own the transaction boundary, not repositories.** Every use
2222 case that writes wraps its work in ` async with self.transaction: ` (a
2323 ` db_retry.Transaction ` ) and calls ` await self.transaction.commit() `
24- explicitly once every write that must land together has been made — e.g.
25- ` CreateMessageUseCase ` commits the new message row and the
24+ explicitly once every write that must land together has been made. For
25+ example, ` CreateMessageUseCase ` commits the new message row and the
2626 ` chats.last_message_id ` update together. This exists because a single
2727 operation can span more than one repository write and they must succeed or
2828 fail as a unit; giving that back to individually auto-committing
@@ -38,12 +38,12 @@ template breaks the transaction model or the DI wiring.
3838
3939## Commands
4040
41- Recipes live in the ` justfile ` — run ` just --list ` to see them; this section
41+ Recipes live in the ` justfile ` ; run ` just --list ` to see them. This section
4242only covers what isn't obvious from the recipe names.
4343
4444Almost everything runs through Docker Compose: the app and Postgres come up
4545together, and running tests/migrations outside Docker is ** not** the
46- supported path (` just install ` and ` just lint ` are the exceptions — they run
46+ supported path (the exceptions are ` just install ` and ` just lint ` , which run
4747on the host). Inside the container, raw commands look like ` uv run pytest ... ` ,
4848` uv run alembic ... ` .
4949
@@ -52,18 +52,18 @@ on the host). Inside the container, raw commands look like `uv run pytest ...`,
5252 e.g. ` just test tests/use_cases/test_create_chat.py -k race -x ` .
5353- ` just test-migrations ` runs the ` pytest-alembic ` suite (` tests/migrations/ ` ):
5454 single head, upgrade, per-revision up/down consistency, and
55- model-definitions-match-DDL — the last being ` alembic check ` as a test. That
55+ model-definitions-match-DDL, the last being ` alembic check ` as a test. That
5656 directory is excluded from ` just test ` (` --ignore ` in ` addopts ` , and from
5757 ` coverage ` 's ` omit ` ) because it cycles the schema out from under the
5858 transaction-rollback fixture, so it needs the ` --override-ini=addopts= ` the
5959 recipe passes. CI runs both.
6060- Enum columns are native Postgres enums, and ` alembic-postgresql-enum ` is
61- imported by ` migrations/env.py ` for its autogenerate hooks — that is what
62- renders ` CREATE TYPE ` / ` ALTER TYPE ... ADD VALUE ` / ` op.sync_enum_values `
61+ imported by ` migrations/env.py ` for its autogenerate hooks. Those hooks
62+ render ` CREATE TYPE ` / ` ALTER TYPE ... ADD VALUE ` / ` op.sync_enum_values `
6363 instead of silently missing them. Add or rename an enum value and
6464 ` just migration ` writes the type change for you.
65- - ` just migration "message" ` takes a ** single positional argument** — not a
66- ` -m ` flag — quoted so a multi-word message survives as one token (the
65+ - ` just migration "message" ` takes a ** single positional argument** ( not a
66+ ` -m ` flag), quoted so a multi-word message survives as one token (the
6767 recipe shell-quotes it with ` quote() ` before handing it to `alembic
6868 revision --autogenerate -m`). It runs against an already-upgraded DB; the
6969 recipe enforces that by upgrading first, so don't run autogen by hand.
@@ -102,28 +102,28 @@ parameters typed `NamedDependency[SomeUseCase]`; nothing wires them by hand.
102102` build_app ` passes ` autowired_groups=[ioc.UseCases] ` to the plugin, which
103103registers one Litestar dependency per provider on that group, named after the
104104provider attribute (` create_chat_use_case ` , ...). ` Database ` and ` Repositories `
105- are deliberately ** not** autowired — a route handler has no business resolving
105+ are deliberately ** not** autowired: a route handler has no business resolving
106106a session, a transaction or a repository directly. Provider scopes:
107- - ` Database.database_engine ` — app-scoped factory, ` cache= ` finalizer disposes
107+ - ` Database.database_engine ` : app-scoped factory, ` cache= ` finalizer disposes
108108 the engine.
109- - ` Database.database_session ` — request-scoped, finalizer closes the session.
110- - ` Database.transaction ` — request-scoped ` db_retry.Transaction ` , the object
109+ - ` Database.database_session ` : request-scoped, finalizer closes the session.
110+ - ` Database.transaction ` : request-scoped ` db_retry.Transaction ` , the object
111111 every use case wraps its work in.
112- - ` Database.autocommit_transaction ` — the same, opened at ` AUTOCOMMIT ` so a
112+ - ` Database.autocommit_transaction ` : the same, opened at ` AUTOCOMMIT ` so a
113113 use case that sends exactly one read statement (login, ` GET /auth/me/ ` , the
114114 chat list and detail, the token revocation check) sends no BEGIN or COMMIT.
115115 Reads of two or more statements keep ` Database.transaction ` , so they see
116116 one snapshot. ` rolled_back_db_session `
117117 overrides ` Database.autocommit_isolation_level ` to ` None ` , because switching
118118 the shared test connection would roll back its outer transaction.
119- - ` Repositories.* ` — request-scoped, ` auto_commit=False ` (see above).
120- - ` UseCases.* ` — request-scoped, one class per operation.
119+ - ` Repositories.* ` : request-scoped, ` auto_commit=False ` (see above).
120+ - ` UseCases.* ` : request-scoped, one class per operation.
121121
122122** Persistence** : Models inherit ` advanced_alchemy.base.BigIntAuditBase ` /
123123` BigIntBase ` . ` app/database/tables.py ` shares metadata with
124124` orm.DeclarativeBase.metadata ` (`METADATA = orm_registry.metadata;
125- orm.DeclarativeBase.metadata = METADATA`) so Alembic autogen sees everything —
126- this line mutates a third-party base class at import time, because otherwise
125+ orm.DeclarativeBase.metadata = METADATA`) so Alembic autogen sees everything.
126+ This line mutates a third-party base class at import time, because otherwise
127127models register on ` orm.DeclarativeBase ` 's own metadata and autogen sees no
128128tables at all. Repositories are
129129` SQLAlchemyAsyncRepositoryService[Model] ` with a nested
@@ -134,22 +134,22 @@ template, but every service here is constructed with `auto_commit=False`.
134134starts a transaction, then ** overrides** ` Database.database_engine ` in the DI
135135container to return that connection; ` create_session ` 's
136136` join_transaction_mode="create_savepoint" ` is what makes every session opened
137- against it — fixture or route handler — nest as a savepoint instead of
137+ against it ( fixture or route handler) nest as a savepoint instead of
138138committing past the outer transaction. Teardown rolls the outer transaction
139139back. ` app ` /` client ` fixtures build the real app and run it through
140140` httpx.ASGITransport ` + ` asgi_lifespan.LifespanManager ` .
141141`modern_di_pytest.expose(ioc.Repositories, ioc.UseCases,
142142container_fixture="request_container")` ( ` tests/use_cases/conftest.py`)
143- exposes every repository/use case provider as a same-named pytest fixture —
143+ exposes every repository/use case provider as a same-named pytest fixture;
144144the template predates this and hand-assembles dependencies instead. The
145145concurrent-create tests in ` tests/use_cases/test_create_chat.py ` and
146146` tests/use_cases/test_create_message.py ` use a second real connection: it holds
147147an uncommitted insert, and the test polls ` pg_stat_activity ` until the use case
148148waits on its lock. The invariant each one pins is in its docstring.
149149
150150** Migrations** : ` migrations/env.py ` reads the shared ` METADATA ` and rewrites
151- the DSN driver from ` postgresql+asyncpg ` → ` postgresql ` (Alembic uses sync
152- psycopg2). Always run autogen against an upgraded DB — ` just migration `
151+ the DSN driver from ` postgresql+asyncpg ` to ` postgresql ` (Alembic uses sync
152+ psycopg2). Always run autogen against an upgraded DB; ` just migration `
153153enforces this.
154154
155155** Settings** (` app/settings.py ` ): ` pydantic_settings.BaseSettings ` reads from
@@ -174,8 +174,8 @@ env vars (see `docker-compose.yml`). `api_bootstrapper_config` builds the
174174 frozen=True, slots=True)` per operation with an async ` __ call__ ` decorated
175175 ` @db_retry.postgres_retry ` . Shared authorization logic that more than one
176176 use case needs (e.g. the author-and-member gate for edit/delete) lives in a
177- plain module-level function, not a base class — see
178- ` app/use_cases/message_authorization.py ` .
177+ plain module-level function, not a base class ( see
178+ ` app/use_cases/message_authorization.py ` ) .
179179- Pydantic schemas in ` app/schemas/api.py ` use ` from_attributes=True ` (via
180180 ` Base ` ) so they validate directly from ORM instances
181181 (` schemas.X.model_validate(orm_instance) ` ). Collection responses go through
@@ -186,22 +186,22 @@ env vars (see `docker-compose.yml`). `api_bootstrapper_config` builds the
186186 exceptions (` NotFoundError ` , ` DuplicateKeyError ` , ` ForeignKeyError ` ).
187187- ** Comments.** None, unless the code would read as a bug without one; then a
188188 single line. Rationale, design decisions and "why not X" belong in
189- the PR body, never in the source — that is where such reasoning is reviewed
190- and kept, and a comment restating it goes stale in place.
189+ the PR body, never in the source. The PR body is where such reasoning is
190+ reviewed and kept, and a comment restating it goes stale in place.
191191 What survives in ` app/ ` today is the whole permitted category: a setting that
192192 looks arbitrary (` join_transaction_mode ` , ` populate_existing ` ,
193193 ` capture_parameters=False ` ), an ` orm.foreign() ` on a column with no
194194 ` ForeignKey ` , a ` return ` from inside a transaction block, discarded work that
195195 is not dead code (` AuthenticateUserUseCase ` 's hash-anyway). Alembic's own
196- ` # ### ` autogenerate markers stay — they are regenerated on every migration.
196+ ` # ### ` autogenerate markers stay, since they are regenerated on every migration.
197197- ** Coverage.** The suite runs at ` --cov-fail-under=100 ` and no ` # pragma: no cover `
198198 appears anywhere. The only exemptions are structural: ` [tool.coverage.run] omit `
199199 for files the default run never imports, and ` exclude_also ` for
200200 ` if typing.TYPE_CHECKING: ` . A branch that looks untestable gets a test that
201201 produces its condition for real, such as the concurrent create tests'
202202 second connection holding an uncommitted insert.
203- - ` ruff ` is configured with ` select = ["ALL"] ` and a line length of 120 —
204- expect strict lint. Type-check with ` ty ` .
203+ - ` ruff ` is configured with ` select = ["ALL"] ` and a line length of 120,
204+ so expect strict lint. Type-check with ` ty ` .
205205
206206## Vocabulary
207207
0 commit comments