Skip to content

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_mixin at import, its own deprecated set_async_context); neither is actionable in the project.
  • migrations/env.py and script.py.mako are Advanced Alchemy's own init output copied verbatim and excluded from ruff and deptry, so regenerating them stays a no-diff operation instead of fighting five type checkers.
  • db/migrations.py builds a config instance at import because alchemy --config imports an attribute and does not call a factory; nothing in the application imports it.
  • The CLI's rich-click dependency lives in an alembic dependency group used only by that tox env (tox runs from the lockfile and ignores deps).

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.