Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 31 additions & 5 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,24 +21,50 @@ Python is 3.14, dependencies managed by `uv`. The API is exposed on `:8000`.

**Request flow**: `app/__main__.py` → `granian` → `app.application:build_app` (factory) → `LitestarBootstrapper` from `lite-bootstrap` wraps a `litestar.Litestar` with OpenTelemetry (SQLAlchemy instrumentor), Sentry, CORS, Swagger, etc., based on `Settings.api_bootstrapper_config`.

**Dependency injection** (`app/ioc.py`): `modern_di.Container` is created in `build_app` with the `Dependencies` group and attached via `modern_di_litestar.ModernDIPlugin`. Route handlers receive repositories as parameters; `application.py` declares them with `modern_di_litestar.FromDI(...)` so Litestar resolves them per-request. Provider scopes:
**Dependency injection** (`app/ioc.py`): `modern_di.Container` is created in `build_app` with `ioc.ALL_GROUPS` and attached via `modern_di_litestar.ModernDIPlugin(di_container, autowired_groups=[ioc.UseCases])`. The plugin registers every `UseCases` provider as a Litestar dependency under its attribute name, so a handler asks for `list_decks_use_case: NamedDependency[ListDecksUseCase]` and nothing is declared in `application.py`. Handlers call the use case and convert the result to a schema; they never touch repositories or the session.

`Database` group:
- `database_engine` — application-scoped primary engine, finalizer disposes the engine
- `database_read_engine` — application-scoped read engine built from `DB_REPLICA_DSN`, or from `DB_DSN` when it is unset. It runs in `AUTOCOMMIT` (no BEGIN/ROLLBACK per request, and the pre-ping is a bare `;`) and sets `default_transaction_read_only=on`, so Postgres rejects writes on it. Each statement sees its own snapshot, so a multi-query read (e.g. `fetch_with_cards`) can see a write that commits in between. Without a replica it is a second pool on the primary, so a process can hold up to `2 × db_pool_size` connections
- `database_read_engine` — application-scoped read engine built from `DB_REPLICA_DSN`, or from `DB_DSN` when it is unset. It runs in `AUTOCOMMIT` (no BEGIN/ROLLBACK per request) and sets `default_transaction_read_only=on`, so Postgres rejects writes on it. Each statement sees its own snapshot, so a multi-query read (e.g. `fetch_with_cards`) can see a write that commits in between. Without a replica it is a second pool on the primary, so a process can hold up to `2 × db_pool_size` connections
- `dynamic_engine` — request-scoped; `choose_sa_engine` returns the read engine for `GET`/`HEAD` requests, 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` — request-scoped, bound to `dynamic_engine`, finalizer closes the session
- `*_repository` — request-scoped, depend on `session`, configured with `auto_commit=True` (advanced-alchemy commits on success / rolls back on exception)
- `transaction` — request-scoped `db_retry.Transaction` over `session`. Leaving its block rolls back anything uncommitted and closes the session

`Repositories` group (request-scoped): `decks_repository` and `cards_repository`, on `Database.session` with `auto_commit=False`. The use case commits through `transaction.commit()`.

`UseCases` group (request-scoped): 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`.

**Use cases and lost connections**: `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:

```python
@dataclasses.dataclass(kw_only=True, frozen=True, slots=True)
class ListDecksUseCase:
transaction: Transaction
decks_repository: DecksRepository

@postgres_retry
async def __call__(self) -> Sequence[models.Deck]:
async with self.transaction:
decks: typing.Final = await self.decks_repository.get_many()
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 with `PendingRollbackError`.
- 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, so a non-idempotent write such as `CreateDeckUseCase` can be applied twice.

**Persistence**: Models inherit `advanced_alchemy.base.BigIntAuditBase` (gives `id: BigInt`, `created_at`, `updated_at`). Metadata is shared with `orm.DeclarativeBase.metadata` in `app/models.py` so Alembic autogen sees everything. Repositories are `SQLAlchemyAsyncRepositoryService[Model]` with a nested `BaseRepository(SQLAlchemyAsyncRepository[Model])`. `create_session` in `app/resources/db.py` builds a plain `AsyncSession` with `join_transaction_mode="create_savepoint"`. This is inert in production (the session binds to an engine) but enables the per-test rollback fixture: 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.

**Test isolation** (`tests/conftest.py`): `db_session` fixture opens a connection, starts a transaction, then **overrides** `Dependencies.dynamic_engine` in the DI container to return that connection. All requests in the test reuse this connection. Each session built against it uses `join_transaction_mode="create_savepoint"`, so `auto_commit` releases the session's own savepoint while the outer transaction is rolled back in teardown. DB state is clean between tests with no truncation needed. `app` and `client` fixtures build the real app and run it through `httpx.ASGITransport` + `asgi_lifespan.LifespanManager`. Polyfactory `SQLAlchemyFactory` is wired up via `set_async_session_in_base_sqlalchemy_factory`.
**Test isolation** (`tests/conftest.py`): `db_session` fixture opens a connection, starts a transaction, then **overrides** `Database.dynamic_engine` in the DI container to return that connection. All requests in the test reuse this connection. Each session built against it uses `join_transaction_mode="create_savepoint"`, so `transaction.commit()` releases the session's own savepoint while the outer transaction is rolled back in teardown. DB state is clean between tests with no truncation needed. `app` and `client` fixtures build the real app and run it through `httpx.ASGITransport` + `asgi_lifespan.LifespanManager`. Polyfactory `SQLAlchemyFactory` is wired up via `set_async_session_in_base_sqlalchemy_factory`.

**Migrations**: `migrations/env.py` reads `app.models.METADATA` and rewrites the DSN driver from `postgresql+asyncpg` → `postgresql+psycopg` (Alembic runs on sync psycopg 3). Always run autogen against an upgraded DB — `just migration` enforces this.

**Settings** (`app/settings.py`): `pydantic_settings.BaseSettings` reads from env vars (see `docker-compose.yml` for `SERVICE_DEBUG`, `SERVICE_ENVIRONMENT`, `DB_DSN`; `DB_REPLICA_DSN` is optional). `api_bootstrapper_config` builds the `LitestarConfig` consumed by `lite-bootstrap`.

## Conventions

- Routes live in `app/api/`, one module per resource (`decks.py`, `cards.py`), each exposing its own `ROUTER` (`litestar.Router`, prefix `/api`). `application.build_app` registers them all via `route_handlers=[decks.ROUTER, cards.ROUTER]`. Add a new resource by creating `app/api/<name>.py`, defining handlers + a `ROUTER`, and adding it to that list.
- Routes live in `app/api/`, one module per resource (`decks.py`, `cards.py`), each exposing its own `ROUTER` (`litestar.Router`, prefix `/api`). `application.build_app` registers them all via `route_handlers=[decks.ROUTER, cards.ROUTER]`. Add a new resource by creating `app/api/<name>.py`, defining handlers + a `ROUTER`, and adding it to that list. Its database work goes in `app/use_cases/` with a provider in `ioc.UseCases`.
- Pydantic schemas in `app/schemas.py` use `from_attributes=True` (via `Base`) so they validate directly from ORM instances (`schemas.X.model_validate(orm_instance)`). Collection responses go through `Collection[T].from_models(...)` (e.g. `schemas.Decks`, `schemas.Cards`).
- Deck responses are deliberately two-shaped: `list_decks`/`create_deck`/`update_deck` return the light `schemas.Deck` (no `cards`), while `get_deck` returns `schemas.DeckWithCards`. The split mirrors loading — lists use `noload` (no cards query), detail uses `selectinload` via `fetch_with_cards` — so the type states exactly what each endpoint loads.
- Domain exceptions: register handlers in `application.build_app`'s `exception_handlers` dict (`DuplicateKeyError` → `exceptions.duplicate_key_error_handler` returns 400, `NotFoundError` → `exceptions.not_found_error_handler` returns 404). Handlers don't raise HTTP errors themselves: a missing row surfaces as advanced-alchemy's `NotFoundError`.
Expand Down
31 changes: 17 additions & 14 deletions app/api/cards.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,36 +4,39 @@
from litestar.di import NamedDependency
from litestar.params import FromPath

from app import models, schemas
from app.repositories import CardsRepository
from app import schemas
from app.use_cases.create_cards import CreateCardsUseCase
from app.use_cases.get_card import GetCardUseCase
from app.use_cases.list_cards import ListCardsUseCase
from app.use_cases.update_cards import UpdateCardsUseCase


@litestar.get("/decks/{deck_id:int}/cards/")
async def list_cards(deck_id: FromPath[int], cards_repository: NamedDependency[CardsRepository]) -> schemas.Cards:
objects = await cards_repository.list_for_deck(deck_id)
return schemas.Cards.from_models(objects)
async def list_cards(deck_id: FromPath[int], list_cards_use_case: NamedDependency[ListCardsUseCase]) -> schemas.Cards:
return schemas.Cards.from_models(await list_cards_use_case(deck_id=deck_id))


@litestar.get("/cards/{card_id:int}/")
async def get_card(card_id: FromPath[int], cards_repository: NamedDependency[CardsRepository]) -> schemas.Card:
instance = await cards_repository.get_one(models.Card.id == card_id)
return schemas.Card.model_validate(instance)
async def get_card(card_id: FromPath[int], get_card_use_case: NamedDependency[GetCardUseCase]) -> schemas.Card:
return schemas.Card.model_validate(await get_card_use_case(card_id=card_id))


@litestar.post("/decks/{deck_id:int}/cards/")
async def create_cards(
deck_id: FromPath[int], data: list[schemas.CardCreate], cards_repository: NamedDependency[CardsRepository]
deck_id: FromPath[int],
data: list[schemas.CardCreate],
create_cards_use_case: NamedDependency[CreateCardsUseCase],
) -> schemas.Cards:
objects = await cards_repository.add_cards(deck_id, data)
return schemas.Cards.from_models(objects)
return schemas.Cards.from_models(await create_cards_use_case(deck_id=deck_id, data=data))


@litestar.put("/decks/{deck_id:int}/cards/")
async def update_cards(
deck_id: FromPath[int], data: list[schemas.Card], cards_repository: NamedDependency[CardsRepository]
deck_id: FromPath[int],
data: list[schemas.Card],
update_cards_use_case: NamedDependency[UpdateCardsUseCase],
) -> schemas.Cards:
objects = await cards_repository.upsert_cards(deck_id, data)
return schemas.Cards.from_models(objects)
return schemas.Cards.from_models(await update_cards_use_case(deck_id=deck_id, data=data))


ROUTER: typing.Final = litestar.Router(
Expand Down
27 changes: 14 additions & 13 deletions app/api/decks.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,35 +5,36 @@
from litestar.params import FromPath

from app import schemas
from app.repositories import DecksRepository
from app.use_cases.create_deck import CreateDeckUseCase
from app.use_cases.get_deck import GetDeckUseCase
from app.use_cases.list_decks import ListDecksUseCase
from app.use_cases.update_deck import UpdateDeckUseCase


@litestar.get("/decks/")
async def list_decks(decks_repository: NamedDependency[DecksRepository]) -> schemas.Decks:
objects = await decks_repository.get_many()
return schemas.Decks.from_models(objects)
async def list_decks(list_decks_use_case: NamedDependency[ListDecksUseCase]) -> schemas.Decks:
return schemas.Decks.from_models(await list_decks_use_case())


@litestar.get("/decks/{deck_id:int}/")
async def get_deck(deck_id: FromPath[int], decks_repository: NamedDependency[DecksRepository]) -> schemas.DeckWithCards:
instance = await decks_repository.fetch_with_cards(deck_id)
return schemas.DeckWithCards.model_validate(instance)
async def get_deck(deck_id: FromPath[int], get_deck_use_case: NamedDependency[GetDeckUseCase]) -> schemas.DeckWithCards:
return schemas.DeckWithCards.model_validate(await get_deck_use_case(deck_id=deck_id))


@litestar.put("/decks/{deck_id:int}/")
async def update_deck(
deck_id: FromPath[int],
data: schemas.DeckCreate,
decks_repository: NamedDependency[DecksRepository],
update_deck_use_case: NamedDependency[UpdateDeckUseCase],
) -> schemas.Deck:
instance = await decks_repository.update(data=data.model_dump(), item_id=deck_id)
return schemas.Deck.model_validate(instance)
return schemas.Deck.model_validate(await update_deck_use_case(deck_id=deck_id, data=data))


@litestar.post("/decks/")
async def create_deck(data: schemas.DeckCreate, decks_repository: NamedDependency[DecksRepository]) -> schemas.Deck:
instance = await decks_repository.create(data)
return schemas.Deck.model_validate(instance)
async def create_deck(
data: schemas.DeckCreate, create_deck_use_case: NamedDependency[CreateDeckUseCase]
) -> schemas.Deck:
return schemas.Deck.model_validate(await create_deck_use_case(data=data))


ROUTER: typing.Final = litestar.Router(
Expand Down
10 changes: 3 additions & 7 deletions app/application.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from litestar.config.app import AppConfig
from opentelemetry.instrumentation.sqlalchemy import SQLAlchemyInstrumentor

from app import exceptions, ioc, repositories
from app import exceptions, ioc
from app.api import cards, decks
from app.settings import settings

Expand All @@ -18,7 +18,7 @@


def build_app() -> litestar.Litestar:
di_container = modern_di.Container(groups=[ioc.Dependencies])
di_container = modern_di.Container(groups=ioc.ALL_GROUPS)
bootstrap_config = dataclasses.replace(
settings.api_bootstrapper_config,
application_config=AppConfig(
Expand All @@ -27,11 +27,7 @@ def build_app() -> litestar.Litestar:
NotFoundError: exceptions.not_found_error_handler,
},
route_handlers=[decks.ROUTER, cards.ROUTER],
plugins=[modern_di_litestar.ModernDIPlugin(di_container)],
dependencies={
"decks_repository": modern_di_litestar.FromDI(repositories.DecksRepository),
"cards_repository": modern_di_litestar.FromDI(repositories.CardsRepository),
},
plugins=[modern_di_litestar.ModernDIPlugin(di_container, autowired_groups=[ioc.UseCases])],
request_max_body_size=settings.request_max_body_size,
),
opentelemetry_instrumentors=[SQLAlchemyInstrumentor()],
Expand Down
40 changes: 35 additions & 5 deletions app/ioc.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import typing

from db_retry import Transaction
from modern_di import Group, Scope, providers

from app.repositories import CardsRepository, DecksRepository
Expand All @@ -9,9 +12,17 @@
create_read_sa_engine,
create_session,
)
from app.use_cases.create_cards import CreateCardsUseCase
from app.use_cases.create_deck import CreateDeckUseCase
from app.use_cases.get_card import GetCardUseCase
from app.use_cases.get_deck import GetDeckUseCase
from app.use_cases.list_cards import ListCardsUseCase
from app.use_cases.list_decks import ListDecksUseCase
from app.use_cases.update_cards import UpdateCardsUseCase
from app.use_cases.update_deck import UpdateDeckUseCase


class Dependencies(Group):
class Database(Group):
database_engine = providers.Factory(
creator=create_primary_sa_engine,
cache=providers.CacheSettings(finalizer=close_sa_engine),
Expand All @@ -33,14 +44,33 @@ class Dependencies(Group):
cache=providers.CacheSettings(finalizer=close_session),
kwargs={"engine": dynamic_engine},
)
transaction = providers.Factory(
scope=Scope.REQUEST,
creator=Transaction,
kwargs={"session": session},
)


class Repositories(Group, scope=Scope.REQUEST):
decks_repository = providers.Factory(
scope=Scope.REQUEST,
creator=DecksRepository,
kwargs={"auto_commit": True, "session": session},
kwargs={"session": Database.session, "auto_commit": False},
)
cards_repository = providers.Factory(
scope=Scope.REQUEST,
creator=CardsRepository,
kwargs={"auto_commit": True, "session": session},
kwargs={"session": Database.session, "auto_commit": False},
)


class UseCases(Group, scope=Scope.REQUEST):
list_decks_use_case = providers.Factory(creator=ListDecksUseCase)
get_deck_use_case = providers.Factory(creator=GetDeckUseCase)
update_deck_use_case = providers.Factory(creator=UpdateDeckUseCase)
create_deck_use_case = providers.Factory(creator=CreateDeckUseCase)
list_cards_use_case = providers.Factory(creator=ListCardsUseCase)
get_card_use_case = providers.Factory(creator=GetCardUseCase)
create_cards_use_case = providers.Factory(creator=CreateCardsUseCase)
update_cards_use_case = providers.Factory(creator=UpdateCardsUseCase)


ALL_GROUPS: typing.Final[list[type[Group]]] = [Database, Repositories, UseCases]
2 changes: 1 addition & 1 deletion app/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ class Settings(pydantic_settings.BaseSettings):
db_replica_dsn: str = ""
db_pool_size: int = 5
db_max_overflow: int = 0
db_pool_pre_ping: bool = True
db_pool_pre_ping: bool = False

app_host: str = "0.0.0.0" # noqa: S104
app_port: int = 8000
Expand Down
Empty file added app/use_cases/__init__.py
Empty file.
21 changes: 21 additions & 0 deletions app/use_cases/create_cards.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import dataclasses
import typing
from collections.abc import Sequence

from db_retry import Transaction, postgres_retry

from app import models, schemas
from app.repositories import CardsRepository


@dataclasses.dataclass(kw_only=True, frozen=True, slots=True)
class CreateCardsUseCase:
transaction: Transaction
cards_repository: CardsRepository

@postgres_retry
async def __call__(self, *, deck_id: int, data: list[schemas.CardCreate]) -> Sequence[models.Card]:
async with self.transaction:
cards: typing.Final = await self.cards_repository.add_cards(deck_id, data)
await self.transaction.commit()
return cards
20 changes: 20 additions & 0 deletions app/use_cases/create_deck.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import dataclasses
import typing

from db_retry import Transaction, postgres_retry

from app import models, schemas
from app.repositories import DecksRepository


@dataclasses.dataclass(kw_only=True, frozen=True, slots=True)
class CreateDeckUseCase:
transaction: Transaction
decks_repository: DecksRepository

@postgres_retry
async def __call__(self, *, data: schemas.DeckCreate) -> models.Deck:
async with self.transaction:
deck: typing.Final = await self.decks_repository.create(data.model_dump())
await self.transaction.commit()
return deck
20 changes: 20 additions & 0 deletions app/use_cases/get_card.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import dataclasses
import typing

from db_retry import Transaction, postgres_retry

from app import models
from app.repositories import CardsRepository


@dataclasses.dataclass(kw_only=True, frozen=True, slots=True)
class GetCardUseCase:
transaction: Transaction
cards_repository: CardsRepository

@postgres_retry
async def __call__(self, *, card_id: int) -> models.Card:
async with self.transaction:
card: typing.Final = await self.cards_repository.get_one(models.Card.id == card_id)
await self.transaction.commit()
return card
Loading
Loading