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
94 changes: 94 additions & 0 deletions docs/adr/0008-layered-architecture.md
Original file line number Diff line number Diff line change
@@ -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.
95 changes: 95 additions & 0 deletions docs/adr/0009-docker-and-compose-strategy.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 2 additions & 0 deletions docs/adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Loading