diff --git a/docs/adr/0008-layered-architecture.md b/docs/adr/0008-layered-architecture.md new file mode 100644 index 0000000..edb79bd --- /dev/null +++ b/docs/adr/0008-layered-architecture.md @@ -0,0 +1,94 @@ +# ADR-0008: Layered Architecture with FastAPI Dependency Injection + +Date: 2026-04-02 + +## Status + +Accepted + +## Context + +Python and FastAPI impose no project structure. The common alternatives +for a single-domain REST API are: + +- **Flat structure**: all application code in one or a few modules at the + root. Simplest to start, but HTTP handling, business logic, ORM queries, + and Pydantic models quickly intermingle, making the code hard to test + or reason about in isolation. +- **Repository pattern**: a dedicated repository layer between the service + and the ORM. Common in Java/Spring Boot; adds a class hierarchy and + interface contracts that duplicate what SQLAlchemy already provides for + a CRUD project. +- **Hexagonal / clean architecture**: ports and adapters with abstract + interfaces for every external dependency. Maximum decoupling, but + significant boilerplate for a single-domain PoC. +- **Layered architecture with FastAPI's native DI**: three functional + layers (routes, services, database) with FastAPI's `Depends()` mechanism + for async session injection. No custom DI container; the framework + handles construction and lifecycle of session objects. + +An additional constraint: SQLAlchemy ORM models (the database schema) and +Pydantic models (the API contract) serve different purposes and must be +kept separate to avoid coupling the wire format to the storage schema. + +## Decision + +We will use a three-layer architecture where each layer has a single, +explicit responsibility, and async SQLAlchemy sessions are injected via +FastAPI's `Depends()` mechanism. + +```text +routes/ → services/ → schemas/ (SQLAlchemy) → SQLite via aiosqlite +``` + +- **`routes/`** (HTTP layer): FastAPI `APIRouter` definitions. Each route + function handles HTTP concerns only — parameter extraction, status codes, + and dispatching to a service function. Routes receive an `AsyncSession` + via `Annotated[AsyncSession, Depends(generate_async_session)]`; session + management (commit, rollback, close) is handled inside the service or + via the session context manager. +- **`services/`** (business layer): module-level async functions, not + classes. Each function accepts an `AsyncSession` as its first parameter + and owns all business logic — existence checks, conflict detection, + cache management, and ORM interactions. Services have no knowledge of + HTTP types. +- **`schemas/`** (data layer): SQLAlchemy 2.0 `DeclarativeBase` models + that define the database schema. These are never serialised directly + to API responses. +- **`models/`**: Pydantic models (`PlayerRequestModel`, + `PlayerResponseModel`) for request validation and response serialisation. + Kept strictly separate from the ORM schema to avoid coupling the API + contract to storage column names or types. +- **`databases/`**: async session factory (`generate_async_session`) used + as the `Depends()` target. The engine and session configuration live here + and nowhere else. + +Services are implemented as plain functions (not classes with injected +interfaces) because FastAPI's `Depends()` already provides lifecycle +management for the session, and functional composition is idiomatic in +Python for stateless service logic. + +## Consequences + +**Positive:** +- Each layer has a single, testable responsibility. Route tests via + `TestClient` exercise the full stack; session injection is transparent. +- FastAPI handles session construction, teardown, and error propagation + through `Depends()` — no composition root or manual wiring is required. +- The ORM/Pydantic split prevents accidental leakage of column names or + ORM-specific types into the API contract. +- The functional service style is idiomatic Python: functions are easy to + call directly in tests without instantiating a class. + +**Negative:** +- Service functions cannot be replaced with test doubles via interface + injection — there are no interface contracts. Testing error branches + requires either fault injection at the database level or patching with + `unittest.mock`. +- The `AsyncSession` parameter must be threaded through every service + function call; adding a new database operation always requires touching + the route signature and the service signature together. +- Contributors familiar with class-based service layers (Spring Boot, + ASP.NET Core, Gin) may expect a similar structure; the functional + approach deviates from the pattern used in the other repos in this + comparison. diff --git a/docs/adr/0009-docker-and-compose-strategy.md b/docs/adr/0009-docker-and-compose-strategy.md new file mode 100644 index 0000000..e9a78e7 --- /dev/null +++ b/docs/adr/0009-docker-and-compose-strategy.md @@ -0,0 +1,95 @@ +# ADR-0009: Docker and Compose Strategy + +Date: 2026-04-02 + +## Status + +Accepted + +## Context + +The project needs to run in a self-contained environment for demos, CI, +and as a reference point in the cross-language comparison. Two concerns +apply: + +1. **Image size and security**: a naive build installs all dependencies + including C build tools (required for native extensions such as + `greenlet` and `aiosqlite`) into the final image, increasing its + size and attack surface. +2. **Local orchestration**: contributors should be able to start the + application with a single command, without installing Python or `uv`, + configuring environment variables, or managing a database file manually. + +Dependency resolution strategies considered: + +- **Single-stage build with pip**: simplest, but requires `build-essential`, + `gcc`, `libffi-dev`, and `libssl-dev` in the final image to compile + native extensions at install time. +- **Multi-stage with virtualenv**: builder creates a `.venv`; runtime + copies it. Works for pure-Python projects but is fragile when native + extensions reference absolute paths baked in during compilation. +- **Multi-stage with pre-built wheels**: builder resolves dependencies via + `uv export` and pre-compiles them into `.whl` files (`pip wheel`); + runtime installs from the local wheelhouse with `--no-index`. Build + tools stay in the builder stage; the final image needs only `pip install`. + +## Decision + +We will use a multi-stage Docker build where the builder stage pre-compiles +all dependency wheels, and Docker Compose to orchestrate the application +locally. + +- **Builder stage** (`python:3.13.3-slim-bookworm`): installs + `build-essential`, `gcc`, `libffi-dev`, and `libssl-dev`; uses + `uv export --frozen --no-dev --no-hashes` to produce a pinned, + reproducible dependency list from `uv.lock`, then compiles every + package into a `.whl` file via `pip wheel`. The wheelhouse is written + to `/app/wheelhouse/`. +- **Runtime stage** (`python:3.13.3-slim-bookworm`): installs `curl` only + (for the health check); copies the pre-built wheels from the builder; + installs them with `--no-index --find-links` (no network access, no + build tools required); removes the wheelhouse after installation. +- **Entrypoint script**: on first start, copies the pre-seeded database + from the image's read-only `hold/` directory to the writable named + volume at `/storage/`, then runs both seed scripts to ensure the schema + and data are up to date. On subsequent starts, the volume file is + preserved and seed scripts run again (they are idempotent). +- **Compose (`compose.yaml`)**: defines a single service with port + mapping (`9000`), a named volume (`storage`), and environment variables + (`STORAGE_PATH`, `PYTHONUNBUFFERED=1`). Health checks are declared in + the Dockerfile (`GET /health`); Compose relies on that declaration. +- A non-root `fastapi` user is created in the runtime stage following the + principle of least privilege. + +## Consequences + +**Positive:** +- Build tools (`gcc`, `libffi-dev`) are confined to the builder stage and + never reach the runtime image — smaller attack surface and faster pulls. +- Offline installation (`--no-index`) eliminates network-related + non-determinism during the runtime image build. +- `uv.lock` pins every transitive dependency; the builder produces the + same wheels regardless of upstream index state. +- `docker compose up` is a complete local setup with no prerequisites + beyond Docker. +- The named volume preserves data across restarts; `docker compose down -v` + resets it cleanly. + +**Negative:** +- Multi-stage builds are more complex to read and maintain than + single-stage builds. +- The wheelhouse is an intermediate artifact: if a wheel cannot be + pre-built (e.g. binary-only distributions without a source distribution), + the builder stage will fail. +- The seed scripts run on every container start. They are idempotent but + add latency to startup and must remain so as the project evolves. +- The SQLite database file is versioned and bundled, meaning schema changes + require a Docker image rebuild. + +**When to revisit:** + +- If a dependency ships only as a binary wheel for the target platform, + the `pip wheel` step may need to be replaced with a direct `pip install` + in the builder stage. +- If a second service (e.g. PostgreSQL) is added, Compose will need a + dedicated network and dependency ordering. diff --git a/docs/adr/README.md b/docs/adr/README.md index 4118800..d2b1981 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -19,3 +19,5 @@ one and the three-part test. | [0005](0005-full-replace-put-no-patch.md) | Full Replace PUT, No PATCH | Accepted | 2026-03-21 | | [0006](0006-in-memory-caching-with-aiocache.md) | In-Memory Caching with aiocache | Accepted | 2026-03-21 | | [0007](0007-integration-only-test-strategy.md) | Integration-Only Test Strategy | Accepted | 2026-03-21 | +| [0008](0008-layered-architecture.md) | Layered Architecture with FastAPI Dependency Injection | Accepted | 2026-04-02 | +| [0009](0009-docker-and-compose-strategy.md) | Docker and Compose Strategy | Accepted | 2026-04-02 |