From 938829d092f175391717a4444a18759b50c07103 Mon Sep 17 00:00:00 2001 From: Nano Taboada <87288+nanotaboada@users.noreply.github.com> Date: Tue, 9 Jun 2026 00:03:56 -0300 Subject: [PATCH] =?UTF-8?q?docs(adr):=20add=20Architecture=20Decision=20Re?= =?UTF-8?q?cords=200001=E2=80=930008=20(#113)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Claude Sonnet 4.6 --- CHANGELOG.md | 2 + CLAUDE.md | 28 ++++++---- ...0001-adopt-rocket-as-rest-api-framework.md | 45 ++++++++++++++++ adr/0002-four-layer-architecture.md | 54 +++++++++++++++++++ adr/0003-diesel-r2d2-bundled-sqlite.md | 46 ++++++++++++++++ ...uuid-surrogate-squad-number-natural-key.md | 51 ++++++++++++++++++ adr/0005-full-replace-put-no-patch.md | 42 +++++++++++++++ adr/0006-embed-migrations-startup-schema.md | 46 ++++++++++++++++ adr/0007-integration-only-test-strategy.md | 52 ++++++++++++++++++ adr/0008-docker-compose-strategy.md | 49 +++++++++++++++++ adr/README.md | 33 ++++++++++++ adr/template.md | 26 +++++++++ 12 files changed, 465 insertions(+), 9 deletions(-) create mode 100644 adr/0001-adopt-rocket-as-rest-api-framework.md create mode 100644 adr/0002-four-layer-architecture.md create mode 100644 adr/0003-diesel-r2d2-bundled-sqlite.md create mode 100644 adr/0004-uuid-surrogate-squad-number-natural-key.md create mode 100644 adr/0005-full-replace-put-no-patch.md create mode 100644 adr/0006-embed-migrations-startup-schema.md create mode 100644 adr/0007-integration-only-test-strategy.md create mode 100644 adr/0008-docker-compose-strategy.md create mode 100644 adr/README.md create mode 100644 adr/template.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 83f3065..2fda3ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,6 +44,8 @@ Release codenames follow an A-Z sequence using Ballon d'Or award nominees surnam ### Added +- Architecture Decision Records in `adr/`: 8 ADRs (0001–0008) documenting framework selection, four-layer architecture, Diesel/r2d2/SQLite persistence stack, dual-key strategy, PUT semantics, embedded migrations, integration-only testing, and Docker/Compose strategy (`#113`) + ### Changed - Consolidated agent instructions from `.github/copilot-instructions.md` into `CLAUDE.md`; deleted the legacy split-file layout (`#112`) diff --git a/CLAUDE.md b/CLAUDE.md index 71ca664..e38ac9f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -70,7 +70,7 @@ cargo test -- --nocapture # with output 3. `cargo build` — must succeed 4. `cargo test` — all tests must pass 5. Commit message follows Conventional Commits format (enforced by commitlint) -6. If this commit introduces or changes an architectural decision, update `CLAUDE.md`. Once #113 is implemented, also create or amend the relevant ADR. +6. If this commit introduces or changes an architectural decision, update `CLAUDE.md` and create or amend the relevant ADR in `adr/`. 7. If this commit adds, removes, or changes an endpoint, a dependency, or how the project is run, update `README.md` and the relevant path instructions in `.coderabbit.yaml`. ### Commits @@ -137,10 +137,6 @@ feat(scope): description (#issue) Co-authored-by: Claude Sonnet 4.6 ``` -## Claude Code - -- Run `/pre-commit` to execute the full pre-commit checklist for this project. - ## Invariants (never change without explicit discussion) - Port: 9000 @@ -152,7 +148,21 @@ Co-authored-by: Claude Sonnet 4.6 ## Architecture Decision Records -Architectural decisions will be documented in `adr/` once issue #113 is -implemented. Until then, key decisions are captured in this file. When proposing -structural changes, check this file first. When a decision changes, update this -file and open or reference the relevant issue. +Architectural decisions are documented in `adr/`. When proposing structural +changes, check the ADR index first. When a decision changes, mark the existing +ADR as `Deprecated` or `Superseded by ADR-XXXX` and write a new one. + +Current ADRs: + +- [ADR-0001](adr/0001-adopt-rocket-as-rest-api-framework.md) — Rocket 0.5 as HTTP framework +- [ADR-0002](adr/0002-four-layer-architecture.md) — Routes → Services → Repositories → State +- [ADR-0003](adr/0003-diesel-r2d2-bundled-sqlite.md) — Diesel + r2d2 + bundled libsqlite3 +- [ADR-0004](adr/0004-uuid-surrogate-squad-number-natural-key.md) — UUID surrogate key + squad number natural key +- [ADR-0005](adr/0005-full-replace-put-no-patch.md) — Full-replace PUT, no PATCH +- [ADR-0006](adr/0006-embed-migrations-startup-schema.md) — `embed_migrations!()` at startup +- [ADR-0007](adr/0007-integration-only-test-strategy.md) — Integration-only tests with in-memory SQLite +- [ADR-0008](adr/0008-docker-compose-strategy.md) — Multi-stage Dockerfile + Compose + +## Claude Code + +- Run `/pre-commit` to execute the full pre-commit checklist for this project. diff --git a/adr/0001-adopt-rocket-as-rest-api-framework.md b/adr/0001-adopt-rocket-as-rest-api-framework.md new file mode 100644 index 0000000..f186245 --- /dev/null +++ b/adr/0001-adopt-rocket-as-rest-api-framework.md @@ -0,0 +1,45 @@ +# ADR-0001: Adopt Rocket as REST API Framework + +Date: 2026-06-09 + +## Status + +Accepted + +## Context + +The project needed a Rust web framework to implement a CRUD REST API for +football players. The Rust web ecosystem offers several mature options: + +- **Axum** — tower-based, ergonomic, strong ecosystem momentum, handler + functions are plain async `fn`; routing is composed programmatically. +- **Actix-web** — actor-model roots, high raw throughput benchmarks, but + introduces more concepts (actors, `Data` extractors) than a simple CRUD + service needs. +- **Warp** — filter combinators compose routes; elegant but the type signature + explosion in complex routes makes compiler errors hard to read. +- **Poem** — OpenAPI-first, macro-driven; less community adoption at the time + of evaluation. +- **Rocket 0.5** — attribute-based routing (`#[get]`, `#[post]`, `#[put]`, + `#[delete]`), `#[launch]` macro minimises `main.rs` boilerplate, async + support via Tokio, strong type-safety for extractors (guards) and managed + state. + +## Decision + +We will use Rocket 0.5.1 with async support via Tokio as the HTTP framework. + +## Consequences + +- **Positive**: Attribute macros on handler functions keep routes declarative + and close to HTTP semantics; handler signatures self-document which HTTP verb + and path they handle. `#[launch]` reduces boilerplate in `main.rs` to a + single annotated function. Rocket's `FromRequest` guard trait provides + type-safe, composable request extraction with clear error paths. +- **Negative**: Rocket has less ecosystem momentum than Axum as of 2025; + community middleware and integrations are fewer. Rocket requires `nightly` + prior to 0.5 — 0.5 finally stabilised on stable, but some documentation + examples still reference nightly features. +- **Neutral**: Async runtime is Tokio; Diesel's synchronous connection pool + (r2d2) is used inside `spawn_blocking` wrappers implicitly via Rocket's + managed state and is not a source of runtime conflicts. diff --git a/adr/0002-four-layer-architecture.md b/adr/0002-four-layer-architecture.md new file mode 100644 index 0000000..d484e0f --- /dev/null +++ b/adr/0002-four-layer-architecture.md @@ -0,0 +1,54 @@ +# ADR-0002: Four-Layer Architecture + +Date: 2026-06-09 + +## Status + +Accepted + +## Context + +The project needed a module structure that keeps HTTP concerns, business logic, +and data access clearly separated. Options considered: + +- **Two-layer (routes + data)** — common in small services; routes contain + business logic and query the database directly. Fast to write, hard to test + independently, and business rules scatter across handler functions. +- **Hexagonal / ports-and-adapters** — clean boundaries via traits and + adapters; strong theoretical separation but adds indirection (trait objects, + mock adapters) that is disproportionate for a single-resource CRUD service. +- **Flat module structure** — everything in one module or one file per concern; + suitable for trivial CLIs, not for a multi-endpoint API that should be + independently maintainable. +- **Four-layer (Routes → Services → Repositories → State)** — each module owns + a single, named concern; layer skipping is prohibited by code review + convention and is naturally discouraged by Rust's module privacy rules. + +## Decision + +We will use a strict four-layer architecture: + +```text +Routes → Services → Repositories → State +``` + +- **Routes** (`src/routes/`): async Rocket handlers; HTTP concerns only + (request parsing, response mapping). No business logic, no Diesel imports. +- **Services** (`src/services/`): pure business logic; returns `Result`; + no `rocket` or `diesel` imports. +- **Repositories** (`src/repositories/`): all Diesel DSL queries; no HTTP + knowledge. +- **State** (`src/state/`): r2d2 connection pool initialisation; runs + `embed_migrations!()` at startup. + +## Consequences + +- **Positive**: Each layer can be reasoned about and tested in isolation. + Business logic in services has no HTTP or SQL surface area, making it the + easiest layer to unit-test if needed. Repository functions group all SQL in + one place, making schema migrations' impact immediately visible. +- **Negative**: A simple field rename touches four files (model, repository, + service, route). There is more boilerplate than a two-layer approach. +- **Neutral**: Rust's module system does not enforce layer boundaries at compile + time, but the convention is clear enough that accidental layer skipping is + caught in code review. diff --git a/adr/0003-diesel-r2d2-bundled-sqlite.md b/adr/0003-diesel-r2d2-bundled-sqlite.md new file mode 100644 index 0000000..aa2feb3 --- /dev/null +++ b/adr/0003-diesel-r2d2-bundled-sqlite.md @@ -0,0 +1,46 @@ +# ADR-0003: Diesel ORM + r2d2 Connection Pool + Bundled SQLite + +Date: 2026-06-09 + +## Status + +Accepted + +## Context + +The project needed an ORM and connection-pooling strategy for SQLite. Options +considered: + +- **SQLx (async)** — compile-time query verification via macros, fully async, + supports SQLite. Requires a live database at compile time (or an offline + snapshot) to verify queries; adds complexity to CI setup. +- **raw `rusqlite`** — direct FFI bindings to libsqlite3; maximum control, but + requires hand-writing all SQL and result mapping with no compile-time safety. +- **SeaORM** — async-first, schema-first ORM with a query builder; heavier + dependency graph; less mature than Diesel at the time of evaluation. +- **Diesel (sync) + r2d2 + `bundled` libsqlite3** — compile-time query + verification via the `table!` macro and DSL type system; r2d2 is the + idiomatic synchronous pool for Diesel; `libsqlite3-sys` with the `bundled` + feature compiles SQLite directly into the binary. + +## Decision + +We will use Diesel 2.x with the r2d2 connection pool and `libsqlite3-sys` +compiled with the `bundled` feature. + +## Consequences + +- **Positive**: Diesel's DSL catches type errors in queries at compile time — + column name typos or type mismatches are build errors, not runtime panics. + The `bundled` feature eliminates the system SQLite dependency: CI, Docker, and + developer machines all build and run identically without `DYLD_LIBRARY_PATH` + or `apt-get install libsqlite3-dev`. +- **Negative**: Diesel is synchronous; connection pool operations block the + thread. In a Rocket async context this is acceptable because Rocket's + `rocket_sync_db_pools` (or manual `spawn_blocking`) handles the boundary. + Diesel's migration CLI is a separate binary that must be installed for schema + work, though `embed_migrations!()` removes the runtime requirement (see + ADR-0006). +- **Neutral**: The `bundled` feature increases binary size slightly by + statically linking SQLite. Schema changes require a new versioned migration + file; the `schema.rs` file is regenerated by `diesel print-schema`. diff --git a/adr/0004-uuid-surrogate-squad-number-natural-key.md b/adr/0004-uuid-surrogate-squad-number-natural-key.md new file mode 100644 index 0000000..a70a8e7 --- /dev/null +++ b/adr/0004-uuid-surrogate-squad-number-natural-key.md @@ -0,0 +1,51 @@ +# ADR-0004: UUID Surrogate Key + Squad Number Natural Key + +Date: 2026-06-09 + +## Status + +Accepted + +## Context + +The project needed a stable identifier strategy for player resources. Options +considered: + +- **Sequential integer primary key** — simple, compact; but auto-increment IDs + are predictable (enumerable), which is a security concern for public-facing + APIs. +- **UUID as the sole key for all operations** — globally unique, non-guessable; + but consumers must look up or store the UUID to perform mutations, and UUIDs + are meaningless in the football domain. +- **Composite key (`squad_number` + `team`)** — natural, domain-meaningful, but + players change teams and the composite makes route paths awkward. +- **Dual key: UUID surrogate + squad number natural key** — UUID provides global + uniqueness and non-guessability; squad number is the domain identifier that + consumers actually know and use for mutations. + +## Decision + +We will use a dual-key approach: + +- `id` (UUID v4, stored as `TEXT` in SQLite): primary key; used only for + `GET /players/{uuid}`. +- `squad_number` (unique integer): natural key; used for all mutation routes + (`PUT /players/squadnumber/{n}`, `DELETE /players/squadnumber/{n}`). + +Both keys are **immutable once set**. On `PUT`, the UUID and squad number from +the stored record are always preserved; values in the request body for these +fields are silently ignored. + +## Consequences + +- **Positive**: UUID prevents ID enumeration attacks on the admin lookup + endpoint. Squad number is the identifier consumers already know, making + mutation routes ergonomic. Immutability removes the need for cascade update + logic. +- **Negative**: SQLite has no native UUID type; the UUID is stored as a 36-char + `TEXT` column, using slightly more space than a 16-byte binary representation. + Two indices (UUID primary key + squad_number unique constraint) add a small + write overhead. +- **Neutral**: `GET /players/{uuid}` is documented as an admin/internal lookup; + consumer-facing queries can use `GET /players` (list all) and + `PUT/DELETE /players/squadnumber/{n}`. diff --git a/adr/0005-full-replace-put-no-patch.md b/adr/0005-full-replace-put-no-patch.md new file mode 100644 index 0000000..733f3b1 --- /dev/null +++ b/adr/0005-full-replace-put-no-patch.md @@ -0,0 +1,42 @@ +# ADR-0005: Full-Replace PUT, No PATCH + +Date: 2026-06-09 + +## Status + +Accepted + +## Context + +The project needed an update semantic for player resources. Options considered: + +- **PATCH only** — partial update; the client sends only changed fields. + Requires the server to define a merge strategy: JSON Merge Patch (RFC 7396) + treats absent fields as "keep existing" and explicit `null` as "clear field"; + JSON Patch (RFC 6902) uses an operation list. Both add complexity and require + careful null/absent disambiguation. +- **Both PUT (full replace) and PATCH (partial update)** — maximum + flexibility; doubles the implementation, test, and documentation surface area + for a single-resource CRUD service. +- **PUT only (full replace)** — the client sends all fields; the server + replaces the stored record entirely (except for immutable keys). No merge + logic required. + +## Decision + +We will implement `PUT /players/squadnumber/{squad_number}` as a full resource +replacement. No `PATCH` endpoint will be provided. + +## Consequences + +- **Positive**: Implementation is straightforward — no merge logic, no need to + choose between RFC 7396 and RFC 6902, no ambiguity about whether an absent + field means "unchanged" or "set to null". Easier to test: a PUT test always + sends a complete payload. +- **Negative**: Clients must fetch the current representation before updating a + single field to avoid accidentally clearing other fields. This is a minor + burden for a flat domain model with roughly ten fields. +- **Neutral**: PUT semantics are well-defined in RFC 7231: the payload + represents the desired final state of the resource. The immutable `id` and + `squad_number` fields are always taken from the stored record, so + "full replace" means all *mutable* fields are replaced. diff --git a/adr/0006-embed-migrations-startup-schema.md b/adr/0006-embed-migrations-startup-schema.md new file mode 100644 index 0000000..9739a59 --- /dev/null +++ b/adr/0006-embed-migrations-startup-schema.md @@ -0,0 +1,46 @@ +# ADR-0006: Embedded Migrations at Startup + +Date: 2026-06-09 + +## Status + +Accepted + +## Context + +The project needed a schema management strategy for the SQLite database. Options +considered: + +- **Manual SQL scripts** — `schema.sql` applied by an operator or CI step + before the server starts. No version tracking; prone to drift between + environments. +- **Runtime migration file loading** — Diesel reads migration files from the + filesystem at startup. Requires the `migrations/` directory to be present at + the deployment path; complicates Docker images and CI environments. +- **No migrations** — schema is created on first run from inline DDL. No + history, no incremental schema evolution, hard to coordinate with seed data. +- **`embed_migrations!()`** — Diesel compiles all migration files into the + binary at build time. At startup, `embedded_migrations::run(&conn)` applies + any pending migrations before the first request is served. + +## Decision + +We will use Diesel's `embed_migrations!()` macro in `src/state/player_collection.rs`. +All pending migrations are applied at startup before the connection pool is +handed to Rocket's managed state. + +## Consequences + +- **Positive**: Migrations are baked into the binary — no external files + required at runtime. The server fails fast at startup if a migration cannot + be applied, preventing requests from reaching a partially-migrated schema. + Behaviour is identical in local development, CI, and Docker without extra + configuration. The seed data migration (`up.sql` in the second migration) + runs automatically in a fresh environment. +- **Negative**: Binary size increases slightly because SQL migration content is + embedded as static strings. Adding a new migration requires rebuilding and + redeploying the binary rather than running a migration tool against a live + database. +- **Neutral**: The `diesel_migrations` crate provides idempotent migration + tracking via a `__diesel_schema_migrations` table; re-running the binary on + an already-migrated database is safe. diff --git a/adr/0007-integration-only-test-strategy.md b/adr/0007-integration-only-test-strategy.md new file mode 100644 index 0000000..c8daa32 --- /dev/null +++ b/adr/0007-integration-only-test-strategy.md @@ -0,0 +1,52 @@ +# ADR-0007: Integration-Only Test Strategy + +Date: 2026-06-09 + +## Status + +Accepted + +## Context + +The project needed a testing strategy that provides meaningful coverage without +excessive setup cost. Options considered: + +- **Unit tests with mocked services** — each layer tested in isolation using + mock implementations; highest granularity, but mocks must be kept in sync + with real implementations and may diverge silently. +- **Unit tests + integration tests** — full coverage pyramid; appropriate for + large systems, but for a single-resource CRUD API the unit/integration split + doubles the maintenance surface without proportional benefit. +- **TestContainers with PostgreSQL** — identical to production DB engine, but + adds container orchestration overhead, increases CI startup time, and moves + away from the SQLite storage decision. +- **Integration tests against in-memory SQLite** — Rocket's `local::Client` + drives full HTTP-level requests; an in-memory pool with `max_size(1)` gives + real schema enforcement and constraint checking with near-zero setup cost. + +## Decision + +We will write integration tests exclusively in the `tests/` directory. Each +test uses either `initialize_test_database()` (26-player seeded pool) or +`initialize_empty_test_database()` (schema only, no rows), calls the service or +issues HTTP requests via fixtures, and follows the Arrange/Act/Assert pattern +with `// Arrange`, `// Act`, `// Assert` section comments. + +Test naming convention: `test_request_{method}_{endpoint}_{condition}_response_{verification}`. + +No mocked services. No unit tests of individual functions. + +## Consequences + +- **Positive**: In-memory SQLite (`max_size(1)`) provides real schema and + constraint enforcement with millisecond setup time. Tests are isolated without + any cleanup step — each test gets its own in-memory database. Rocket's + `local::Client` tests the full HTTP stack, catching route and serialisation + issues that a service-only unit test would miss. +- **Negative**: Individual service functions and repository queries have no + dedicated unit test coverage. A bug in a repository function is only caught by + a test that exercises the full route → service → repository path, which can + make root-cause analysis slower. +- **Neutral**: The in-memory pool uses `max_size(1)` to prevent connection + contention in single-threaded test runs. This is not a production pool + configuration. diff --git a/adr/0008-docker-compose-strategy.md b/adr/0008-docker-compose-strategy.md new file mode 100644 index 0000000..16f3afe --- /dev/null +++ b/adr/0008-docker-compose-strategy.md @@ -0,0 +1,49 @@ +# ADR-0008: Docker and Compose Strategy + +Date: 2026-06-09 + +## Status + +Accepted + +## Context + +The project needed a containerisation strategy for local development and +deployment. Options considered: + +- **Single-stage Dockerfile** — one `FROM rust` image builds and runs the + binary. Simple, but ships the full Rust toolchain (~2 GB) in the runtime + image. +- **Multi-stage without Compose** — smaller image, but no persistent volume + for the SQLite file; the database is lost on container restart. +- **No Docker** — local-only; works for a developer PoC but prevents + consistent environment setup across machines and CI. +- **Multi-stage Dockerfile + Docker Compose** — build stage compiles the + release binary; minimal runtime stage (Debian slim) ships only the binary and + its linked libraries. Compose adds a named volume for SQLite persistence and + exposes port 9000. + +## Decision + +We will use a multi-stage Dockerfile: + +1. **Builder stage** (`rust:slim`): runs `cargo build --release`. +2. **Runtime stage** (`debian:bookworm-slim`): copies the compiled binary; + includes only `libssl` and CA certificates. + +Docker Compose provides a named volume (`sqlite_data`) mounted at `/data` so +the SQLite file persists across container restarts. The container listens on +port 9000 (the project invariant). + +## Consequences + +- **Positive**: The runtime image contains no Rust toolchain, reducing image + size significantly compared to a single-stage build. Named volume ensures + data durability without bind-mounting a host directory. Compose makes local + development a single `docker compose up` command. +- **Negative**: Multi-stage builds increase build time slightly (two image + layers). Any change to system dependencies (e.g., adding a C library) must be + reflected in both stages. +- **Neutral**: The `bundled` libsqlite3 feature (see ADR-0003) means the + runtime image does not need a system SQLite package. The SQLite file path + is configured via `Rocket.toml` and the `DATABASE_URL` environment variable. diff --git a/adr/README.md b/adr/README.md new file mode 100644 index 0000000..c9010bd --- /dev/null +++ b/adr/README.md @@ -0,0 +1,33 @@ +# Architecture Decision Records + +This directory contains Architecture Decision Records (ADRs) for the +`rust-samples-rocket-restful` project. An ADR captures a significant +architectural choice, the context that drove it, and the trade-offs accepted. + +ADRs are immutable once accepted. If a decision changes, the original ADR is +marked `Deprecated` or `Superseded by ADR-XXXX` and a new ADR is written. + +## Index + +| # | Title | Status | +|---|-------|--------| +| [0001](0001-adopt-rocket-as-rest-api-framework.md) | Adopt Rocket as REST API Framework | Accepted | +| [0002](0002-four-layer-architecture.md) | Four-Layer Architecture | Accepted | +| [0003](0003-diesel-r2d2-bundled-sqlite.md) | Diesel ORM + r2d2 + Bundled SQLite | Accepted | +| [0004](0004-uuid-surrogate-squad-number-natural-key.md) | UUID Surrogate Key + Squad Number Natural Key | Accepted | +| [0005](0005-full-replace-put-no-patch.md) | Full-Replace PUT, No PATCH | Accepted | +| [0006](0006-embed-migrations-startup-schema.md) | Embedded Migrations at Startup | Accepted | +| [0007](0007-integration-only-test-strategy.md) | Integration-Only Test Strategy | Accepted | +| [0008](0008-docker-compose-strategy.md) | Docker and Compose Strategy | Accepted | + +## Creating a New ADR + +1. Copy `template.md` to `NNNN-short-title-kebab-case.md` (next sequential number). +2. Fill in all sections. Status starts as `Proposed`; update to `Accepted` once the decision is confirmed. +3. Add a row to the index table above. +4. Update `CLAUDE.md` → "Architecture Decision Records" section if the decision affects agent guidance. + +## References + +- [Documenting Architecture Decisions — Michael Nygard (2011)](https://cognitect.com/blog/2011/11/15/documenting-architecture-decisions) +- [ADR GitHub Organization](https://adr.github.io/) diff --git a/adr/template.md b/adr/template.md new file mode 100644 index 0000000..2961d1f --- /dev/null +++ b/adr/template.md @@ -0,0 +1,26 @@ +# ADR-NNNN: {Title} + + + +Date: YYYY-MM-DD + +## Status + +{Proposed | Accepted | Deprecated | Superseded by ADR-XXXX} + +## Context + +{Describe the forces at play. What problem needs solving? What constraints apply? +List the alternatives considered and their trade-offs.} + +## Decision + +{State the decision in active voice: "We will…"} + +## Consequences + +{Describe the outcomes of this decision.} + +- **Positive**: {benefits gained} +- **Negative**: {trade-offs accepted} +- **Neutral**: {implications that are neither clearly good nor bad}