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
13 changes: 11 additions & 2 deletions copier.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ _exclude:
- "{% if not include_user_example %}**/unit/domain/models/test_user.py{% endif %}"
- "{% if not include_user_example %}**/service_layer/test_handlers.py{% endif %}"
- "{% if not include_user_example %}**/integration/test_persistence.py{% endif %}"
- "{% if not include_user_example %}**/unit/adapters/test_persistence.py{% endif %}"
- "{% if not include_user_example %}**/e2e/entrypoint/test_users.py{% endif %}"

_message_after_copy: |
Expand Down Expand Up @@ -109,10 +110,18 @@ python_version:
- "3.12"
default: "3.13"

database:
type: str
help: Default database engine (async drivers; SQLite stays available for quick starts)
choices:
PostgreSQL (asyncpg, pgvector image): postgres
SQLite (aiosqlite): sqlite
default: postgres

database_url:
type: str
help: Default SQLAlchemy database URL
default: "sqlite+pysqlite:///./{{ project_slug }}.db"
help: Default async SQLAlchemy database URL
default: "{% if database == 'postgres' %}postgresql+asyncpg://{{ project_slug }}:{{ project_slug }}@localhost:5432/{{ project_slug }}{% else %}sqlite+aiosqlite:///./{{ project_slug }}.db{% endif %}"

include_user_example:
type: bool
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ dev = [
"copier>=9.4.0",
"pytest>=8.4.0",
"pre-commit>=4.2.0",
"pyyaml>=6.0",
]

[tool.pytest.ini_options]
Expand Down
39 changes: 39 additions & 0 deletions template/.github/workflows/build.yml.jinja
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,42 @@ jobs:
run: make cover
- name: Build image
run: docker build . --file Dockerfile --tag {{ project_slug }}:{% raw %}${{ github.sha }}{% endraw %}
{%- if database == 'postgres' %}

integration:

runs-on: ubuntu-latest

services:
postgres:
image: pgvector/pgvector:pg17
env:
POSTGRES_USER: {{ project_slug }}
POSTGRES_PASSWORD: {{ project_slug }}
POSTGRES_DB: {{ project_slug }}
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U {{ project_slug }} -d {{ project_slug }}"
--health-interval 5s
--health-timeout 5s
--health-retries 5

env:
TEST_DATABASE_URL: postgresql+asyncpg://{{ project_slug }}:{{ project_slug }}@localhost:5432/{{ project_slug }}

steps:
- uses: actions/checkout@v4
- name: Install uv
uses: astral-sh/setup-uv@v5
with:
enable-cache: true
- name: Install dependencies
run: make dev
- name: Apply migrations
run: uv run alembic upgrade head
env:
DATABASE_URL: {% raw %}${{ env.TEST_DATABASE_URL }}{% endraw %}
- name: Run the PostgreSQL integration tier
run: make integration
{%- endif %}
12 changes: 8 additions & 4 deletions template/Makefile.jinja
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,12 @@ dev: ## Install runtime and dev dependencies
uv sync --dev

.PHONY: test
test: ## Executes tests cases
uv run pytest
test: ## Executes the offline test tiers (unit + e2e)
uv run pytest -m "not integration"

.PHONY: integration
integration: ## Executes the PostgreSQL integration tier (requires TEST_DATABASE_URL / Docker)
uv run pytest -m integration

.PHONY: adr-check
adr-check: ## Validates the architecture decision registry
Expand All @@ -42,8 +46,8 @@ migrate: ## Applies relational database migrations
uv run alembic upgrade head

.PHONY: cover
cover: ## Executes tests cases with coverage reports
uv run pytest --cov src/{{ package_name }} --cov-fail-under=100 --junitxml reports/xunit.xml \
cover: ## Executes the offline tiers (unit + e2e) with the coverage gate
uv run pytest -m "not integration" --cov src/{{ package_name }} --cov-fail-under=100 --junitxml reports/xunit.xml \
--cov-report xml:reports/coverage.xml --cov-report term-missing

.PHONY: format
Expand Down
39 changes: 38 additions & 1 deletion template/docker-compose.yaml.jinja
Original file line number Diff line number Diff line change
Expand Up @@ -12,19 +12,56 @@ services:
# reflected for Uvicorn's live-reload.
volumes:
- ./src:/app/src
{%- if database == 'sqlite' %}
# Persist the SQLite database file across container recreation.
- app-data:/app/data
{%- endif %}
{%- if database == 'postgres' %}
depends_on:
db:
condition: service_healthy
{%- endif %}
environment:
# Enable Uvicorn auto-reload for local development.
- UVICORN_RELOAD=true
{%- if database == 'postgres' %}
# Connect to the bundled PostgreSQL service over the async driver.
- DATABASE_URL=postgresql+asyncpg://{{ project_slug }}:{{ project_slug }}@db:5432/{{ project_slug }}
{%- else %}
# Store the SQLite database on the persistent named volume.
- DATABASE_URL=sqlite+pysqlite:///./data/{{ project_slug }}.db
- DATABASE_URL=sqlite+aiosqlite:///./data/{{ project_slug }}.db
{%- endif %}
# Create the schema at startup so the demo works on first run.
# Production deployments must use Alembic migrations (make migrate)
# instead of auto-creating the schema.
- DATABASE_AUTO_CREATE_SCHEMA=true
# Allow the bundled frontend / local tools to call the API in development.
- FASTAPI_BACKEND_CORS_ORIGINS=["http://localhost:8000"]
{%- if database == 'postgres' %}

db:
# pgvector-capable PostgreSQL so projects can adopt vector search without
# changing infrastructure. The example domain ships no vector column yet.
image: pgvector/pgvector:pg17
container_name: {{ project_slug }}-db
environment:
- POSTGRES_USER={{ project_slug }}
- POSTGRES_PASSWORD={{ project_slug }}
- POSTGRES_DB={{ project_slug }}
ports:
- "5432:5432"
volumes:
- db-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U {{ project_slug }} -d {{ project_slug }}"]
interval: 5s
timeout: 5s
retries: 5
{%- endif %}

volumes:
{%- if database == 'postgres' %}
db-data:
{%- else %}
app-data:
{%- endif %}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# ADR 0006: Async Is an Explicit End-to-End Choice

- Status: Accepted
- Status: Superseded
- Superseded by: [0017](0017-async-persistence-by-default.md)
- Date: 2026-05-31

## Context
Expand Down
65 changes: 65 additions & 0 deletions template/docs/adr/0017-async-persistence-by-default.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# ADR 0017: Async Persistence and Application Code by Default

- Status: Accepted
- Date: 2026-06-12

## Context

[ADR 0006](0006-async-is-an-explicit-end-to-end-choice.md) made synchronous code
the default and treated async as an explicit, per-use-case opt-in. It also
stated that changing the default persistence mode to async requires a new ADR —
this is that ADR.

FastAPI is an async-first framework, and the services this template scaffolds
are I/O-bound (database, HTTP, brokers). Running async end to end lets a single
worker serve many concurrent requests while awaiting I/O, and SQLAlchemy 2's
asyncio extension is now mature and well documented. Maintaining a synchronous
default plus an async opt-in also means two persistence styles to teach and
test. Standardizing on async removes that fork.

## Decision

The template is **async end to end by default**, and ships only the async path.

- Persistence uses `create_async_engine`, `AsyncSession`, and
`async_sessionmaker`.
- The unit of work is an async context manager (`async with uow:`) with
`await uow.commit()` / `await uow.rollback()` and async write-back.
- Repositories and query readers are async and use
`await session.execute(select(...))`.
- Command and event handlers are `async def`; the message bus awaits them.
- FastAPI routes and the application lifespan are async; startup/shutdown await
container hooks.
- Database drivers are async: `asyncpg` for PostgreSQL, `aiosqlite` for SQLite
(`greenlet` is pulled in transitively by SQLAlchemy's async support).

Domain objects stay plain synchronous Python — business rules must not perform
I/O ([ADR 0002](0002-domain-models-are-framework-independent.md)), so they never
become coroutines.

This **supersedes [ADR 0006](0006-async-is-an-explicit-end-to-end-choice.md)**.

## Consequences

The code matches idiomatic modern FastAPI and there is a single persistence
style to learn, maintain, and test. The cost is that async correctness now
matters everywhere: handlers must never block the event loop, tests need an
async runner (`pytest-asyncio`), and contributors must understand `await`
semantics. The domain layer is unaffected and remains trivially unit-testable.

## Agent Guidance

- Make every adapter, service-layer, and entrypoint I/O path `async`/`await`.
- Never call blocking I/O inside an `async def`; offload to a thread only with a
documented reason.
- Keep domain methods synchronous and free of I/O.
- Use `AsyncSession` and `await` commits/queries; do not mix a sync `Session`
into the request path.
- Write async tests; cover the async paths.

## References

- [SQLAlchemy asyncio extension](https://docs.sqlalchemy.org/en/20/orm/extensions/asyncio.html)
- [FastAPI: async and await](https://fastapi.tiangolo.com/async/)
- [pytest-asyncio](https://pytest-asyncio.readthedocs.io/)
- [ADR 0006: Async Is an Explicit End-to-End Choice](0006-async-is-an-explicit-end-to-end-choice.md)
60 changes: 60 additions & 0 deletions template/docs/adr/0018-postgresql-default-with-pgvector.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# ADR 0018: PostgreSQL by Default with a pgvector Image; SQLite Optional

- Status: Accepted
- Date: 2026-06-12

## Context

The template historically defaulted to file-based SQLite, which is excellent for
zero-setup demos but is not what most production services run. Real services
want PostgreSQL: concurrent writers, real types (JSONB, arrays), and increasingly
vector search for embeddings via the `pgvector` extension. A template should
default to the realistic target while still offering a frictionless,
infrastructure-free option.

## Decision

PostgreSQL is the **default** database; SQLite remains a selectable option.

- A Copier `database` question chooses `postgres` (default) or `sqlite`.
- **PostgreSQL**: the `asyncpg` driver and a default URL of the form
`postgresql+asyncpg://…`. `docker-compose` bundles a **pgvector-capable
image** (`pgvector/pgvector:pg17`) as a `db` service with a healthcheck and a
named volume; the `app` service depends on it.
- **SQLite**: the `aiosqlite` driver and a file URL; no database service is
added to `docker-compose`.
- Async drivers only, per [ADR 0017](0017-async-persistence-by-default.md).
- Alembic runs against the async engine.

The pgvector **image** is provided so the extension is available, but the
example domain ships **no vector column**. Projects that need vectors add a
`CREATE EXTENSION IF NOT EXISTS vector` step in a migration and a `Vector`
column in a persistence model when the requirement is real.

## Consequences

New projects start on a production-shaped database and can adopt pgvector without
changing infrastructure. SQLite stays available for quick experiments and is the
substrate for the fast offline test tier
([ADR 0019](0019-coverage-from-unit-and-e2e-tests.md)). The cost is that the
Postgres default expects a running database for local runs; `docker-compose up`
provides one, and `DATABASE_AUTO_CREATE_SCHEMA`/Alembic handle the schema.

## Agent Guidance

- Select the driver through the database URL (`postgresql+asyncpg` /
`sqlite+aiosqlite`); do not hardcode a dialect in adapters.
- Before using a `Vector` column, add `CREATE EXTENSION IF NOT EXISTS vector` in
an Alembic migration.
- Keep persistence-model SQL portable where practical; put genuinely
Postgres-specific behavior behind an integration test
([ADR 0019](0019-coverage-from-unit-and-e2e-tests.md)).
- Manage schema with Alembic; reserve `AUTO_CREATE_SCHEMA` for demos and tests.

## References

- [pgvector](https://github.com/pgvector/pgvector)
- [asyncpg](https://magicstack.github.io/asyncpg/)
- [aiosqlite](https://aiosqlite.omnilib.dev/)
- [Alembic with async engines](https://alembic.sqlalchemy.org/en/latest/cookbook.html#using-asyncio-with-alembic)
- [ADR 0017: Async Persistence and Application Code by Default](0017-async-persistence-by-default.md)
63 changes: 63 additions & 0 deletions template/docs/adr/0019-coverage-from-unit-and-e2e-tests.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# ADR 0019: Coverage From Unit and E2E Tests; Integration Runs Separately

- Status: Accepted
- Date: 2026-06-12

## Context

[ADR 0007](0007-tooling-and-test-pyramid.md) defines the test pyramid
(`unit`, `integration`, `e2e`). With PostgreSQL as the default
([ADR 0018](0018-postgresql-default-with-pgvector.md)) and async persistence
([ADR 0017](0017-async-persistence-by-default.md)), the integration tier needs a
real PostgreSQL instance (Docker). Gating the 100% coverage requirement on tests
that require Docker couples the fast feedback loop — and the offline template
bake — to container infrastructure, which is slow and brittle in that role.

## Decision

Split the suite by what each tier needs and what it gates.

- **`tests/unit`** — pure unit tests with mocked sessions/queries and in-memory
fakes; no database.
- **`tests/e2e`** — routes exercised through the ASGI app with an injected
**in-memory async SQLite** (`aiosqlite`) container; no external infrastructure.
- **`tests/integration`** — repository, unit-of-work, dialect, and migration
behavior against a **real PostgreSQL** (`asyncpg`) via a Docker service
container.

The **100% coverage gate is computed from `unit` + `e2e` only.** These run
offline and deterministically, so adapter code must be reachable by a mocked
unit test or the in-memory e2e path to count. `tests/integration` is **excluded
from coverage** and runs as a **separate CI stage** with a PostgreSQL (pgvector)
service. `make cover` runs the offline gate; `make integration` runs the Docker
tier. The Copier bake matrix runs only the offline gate.

This extends [ADR 0007](0007-tooling-and-test-pyramid.md); 0007 remains Accepted.

## Consequences

Coverage feedback is fast, deterministic, and infrastructure-free, and the
template bakes offline. Real-PostgreSQL behavior (dialect specifics, migrations,
transaction semantics) is still verified, in a dedicated CI job that does not
block the coverage signal. The trade-off: code that only runs against PostgreSQL
must be covered by an integration test for confidence even though it does not
count toward the coverage percentage, so keep such Postgres-only branches thin.

## Agent Guidance

- Put fast behavior in `tests/unit` (mock the `AsyncSession`/reader) and critical
wiring in `tests/e2e` (in-memory async SQLite container).
- Mark real-database tests with the `integration` marker and place them in
`tests/integration`; never rely on them for coverage.
- Keep the offline gate at 100% via `make cover`.
- Verify dialect- or migration-specific behavior in the integration stage.
- Keep `concurrency = ["thread", "greenlet"]` in `[tool.coverage.run]`. SQLAlchemy's
async support resumes coroutines across a greenlet boundary, so without it
coverage misclassifies branches that continue after an awaited database call.

## References

- [pytest markers](https://docs.pytest.org/en/stable/how-to/mark.html)
- [coverage.py configuration](https://coverage.readthedocs.io/en/latest/config.html)
- [ADR 0007: Tooling and Test Pyramid](0007-tooling-and-test-pyramid.md)
- [ADR 0018: PostgreSQL by Default with a pgvector Image](0018-postgresql-default-with-pgvector.md)
5 changes: 4 additions & 1 deletion template/docs/adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ treated as migration work, not as a precedent to repeat.
| [0003](0003-fastapi-and-pydantic-live-at-boundaries.md) | FastAPI and Pydantic Live at Boundaries | Superseded |
| [0004](0004-sqlalchemy-2-persistence-behind-repositories.md) | SQLAlchemy 2 Persistence Stays Behind Repositories | Accepted |
| [0005](0005-explicit-composition-and-message-dispatch.md) | Explicit Composition and Message Dispatch | Accepted |
| [0006](0006-async-is-an-explicit-end-to-end-choice.md) | Async Is an Explicit End-to-End Choice | Accepted |
| [0006](0006-async-is-an-explicit-end-to-end-choice.md) | Async Is an Explicit End-to-End Choice | Superseded |
| [0007](0007-tooling-and-test-pyramid.md) | Tooling and Test Pyramid | Accepted |
| [0008](0008-static-typing-with-pyrefly.md) | Static Typing With Pyrefly | Accepted |
| [0009](0009-conventional-commits.md) | Conventional Commits | Accepted |
Expand All @@ -35,6 +35,9 @@ treated as migration work, not as a precedent to repeat.
| [0014](0014-cqrs-read-models-are-purpose-built.md) | CQRS Read Models Are Purpose Built | Accepted |
| [0015](0015-copier-template-engine.md) | Copier as the Template Engine | Accepted |
| [0016](0016-aggregate-persistence-write-back.md) | Aggregate Persistence Write-Back on Commit | Accepted |
| [0017](0017-async-persistence-by-default.md) | Async Persistence and Application Code by Default | Accepted |
| [0018](0018-postgresql-default-with-pgvector.md) | PostgreSQL by Default with a pgvector Image; SQLite Optional | Accepted |
| [0019](0019-coverage-from-unit-and-e2e-tests.md) | Coverage From Unit and E2E Tests; Integration Runs Separately | Accepted |

## Agent Checklist

Expand Down
Loading
Loading