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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`)
Expand Down
28 changes: 19 additions & 9 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -137,10 +137,6 @@ feat(scope): description (#issue)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
```

## Claude Code

- Run `/pre-commit` to execute the full pre-commit checklist for this project.

## Invariants (never change without explicit discussion)

- Port: 9000
Expand All @@ -152,7 +148,21 @@ Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>

## 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.
45 changes: 45 additions & 0 deletions adr/0001-adopt-rocket-as-rest-api-framework.md
Original file line number Diff line number Diff line change
@@ -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<T>` 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.
54 changes: 54 additions & 0 deletions adr/0002-four-layer-architecture.md
Original file line number Diff line number Diff line change
@@ -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<T, CustomError>`;
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.
46 changes: 46 additions & 0 deletions adr/0003-diesel-r2d2-bundled-sqlite.md
Original file line number Diff line number Diff line change
@@ -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`.
51 changes: 51 additions & 0 deletions adr/0004-uuid-surrogate-squad-number-natural-key.md
Original file line number Diff line number Diff line change
@@ -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}`.
42 changes: 42 additions & 0 deletions adr/0005-full-replace-put-no-patch.md
Original file line number Diff line number Diff line change
@@ -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.
46 changes: 46 additions & 0 deletions adr/0006-embed-migrations-startup-schema.md
Original file line number Diff line number Diff line change
@@ -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.
52 changes: 52 additions & 0 deletions adr/0007-integration-only-test-strategy.md
Original file line number Diff line number Diff line change
@@ -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.
Loading