The opt-in database layer¶
The database layer is async-only Advanced Alchemy behind one include_database toggle¶
Id: 1e420f0b-5bc9-4bbf-ad2d-17e4f5c54ffe Type: decision Status: active Evidence: confirmed Source: issue #255; maintainer decisions taken while implementing it, 2026-10-16 Revisit when: a sync-only adopter need is reported, or Advanced Alchemy 2.0 changes the config/plugin surface
include_database (default off; on in the web and full presets) renders a
db/ subpackage, a root migrations/ Alembic tree, an alembic tox env and,
with include_web, the framework's Advanced Alchemy plugin plus a /health/db
probe. database_backend (sqlite/postgres) only picks the async driver
dependency and the default URL. The layer is usable without include_web; only
the plugin wiring is web-gated. The connection URL always flows through
Settings.database_url (both the pydantic and the stdlib Settings), so there
is no separate "settings vs bare env var" branch.
Reason: Advanced Alchemy ships the declarative base with UUID/audit columns,
a repository layer, session-lifecycle plugins for both web frameworks and an
Alembic wrapper, so the toggle renders one coherent layer instead of boilerplate
the adopter must reconcile. Async-first matches FastAPI/Litestar, and the
async_style toggle (#193) already carries the async test runner.
Rejected alternative: bare SQLAlchemy + hand-rolled Alembic — a pile of setup the adopter would still assemble themselves.
Rejected alternative: an additional sync engine option — roughly doubles the
templates and the test matrix for an audience nobody has asked for. Consequence:
async_style = none is rejected when the toggle is on, and the default
async_style becomes asyncio.
Rejected alternative: a starter-less layer (empty metadata, commented
example) — the first autogenerated revision would be empty. One illustrative
Item model + repository keeps it readable.
database_backend=postgres defaults the devcontainer service but never forces it¶
Id: 287e57fe-acca-4b76-abd2-ac3ecedf4ed0 Type: decision Status: active Evidence: confirmed Source: issue #255; maintainer decision, 2026-10-16 Revisit when: the devcontainer and the database layer start to share more than the default alignment
The database_backend default follows whether the preset enables
include_postgres (web/full → postgres, else sqlite), but the two toggles
stay independent questions.
Reason: keeps toggles orthogonal; an adopter can run a hosted Postgres without the devcontainer service, or the service without the layer.
Rejected alternative: force include_postgres on for the postgres backend —
hard coupling between a runtime layer and a dev-only service.
Consequence: with the postgres backend and the devcontainer service both on,
the devcontainer gets the prefixed DATABASE_URL by the postgres compose
service name, the way worker brokers are injected: inside the devcontainer
127.0.0.1 is the app container, not the database. It lives in
.devcontainer/database.env, an env_file listed before the root .env, not
in environment: — Compose lets environment outrank every env_file, which
would make a URL in the root .env unable to win. .env.example deliberately
carries no URL: the root .env is also read on the host, where the postgres
service name does not resolve. The default targets the postgres database and
user, so changing POSTGRES_USER/POSTGRES_DB means editing database.env. A
hosted Postgres without the service keeps the localhost default.
The default is read from preset_map, not from the
include_postgres answer, because that question is asked later than
database_backend.
db is a layer of its own, between the components and core¶
Id: a8401f62-9be7-4f0d-a617-e345e5967a80
Type: decision
Status: active
Evidence: inferred
Source: implementation of issue #255; [tool.importlinter] in template/pyproject.toml.jinja
Revisit when: a second non-runnable shared layer appears
In the exhaustive import-linter contract db sits below the independent
component group and above core (it reads core.config, and web imports it).
For CI it is still treated as a component: its own marker, path filter,
test-db job and scoped coverage gate, and the web filter also watches
db/** because web imports it.
Rejected alternative: a sibling in the component group — siblings may not
import each other, and web must import db.
Rejected alternative: put it under core/ — core is dependency-light and
shared by every component; the SQLAlchemy stack would leak into projects that
only want a CLI.
Advanced Alchemy workarounds: ignored library warnings, a verbatim Alembic scaffold, an instance for the CLI¶
Id: fe7b3780-e4ba-4cc1-81e0-7dee5a022488
Type: workaround
Status: active
Evidence: confirmed
Source: observed while rendering and running the layer against advanced-alchemy 1.11 and SQLAlchemy 2.1, 2026-10-16
Revisit when: Advanced Alchemy stops emitting its own deprecation warnings, or ships a typed env.py
Four things look unnecessary and are not:
- The generated
filterwarnings = ["error", …]ignores two warnings raised inside Advanced Alchemy itself (declarative_mixinat import, its own deprecatedset_async_context); neither is actionable in the project. migrations/env.pyandscript.py.makoare Advanced Alchemy's owninitoutput copied verbatim and excluded from ruff and deptry, so regenerating them stays a no-diff operation instead of fighting five type checkers.db/migrations.pybuilds a config instance at import becausealchemy --configimports an attribute and does not call a factory; nothing in the application imports it.- The CLI's
rich-clickdependency lives in analembicdependency group used only by that tox env (tox runs from the lockfile and ignoresdeps).
The default SQLite file is db.sqlite3 because the sealed generated
.gitignore already covers it. The suite always points the URL at in-memory
SQLite (tests/conftest.py), so the postgres backend also carries aiosqlite
in the test group and no test reaches a server.
The web database tests enter the client as a context manager¶
Id: 94b7f503-9bb0-4348-bd2c-12c93081e196
Type: constraint
Status: active
Evidence: confirmed
Source: observed 500 from Litestar's TestClient without lifespan, 2026-10-16
Revisit when: the web tests move to an async client
Litestar's plugin creates the engine in its startup hook, so a bare
TestClient(app) returns 500 for the session-backed /health/db route; the
test uses with TestClient(app). The db tests also pin the anyio backend to
asyncio, since SQLAlchemy's async drivers do not run under trio.