Skip to content

Commit f0a4a5d

Browse files
authored
Merge pull request #551 from nanotaboada/docs/add-architecture-and-docker-adrs
docs(adr): add layered architecture and Docker strategy ADRs
2 parents 454a397 + 88c6a33 commit f0a4a5d

File tree

3 files changed

+191
-0
lines changed

3 files changed

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

docs/adr/README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,5 @@ one and the three-part test.
1919
| [0005](0005-full-replace-put-no-patch.md) | Full Replace PUT, No PATCH | Accepted | 2026-03-21 |
2020
| [0006](0006-in-memory-caching-with-aiocache.md) | In-Memory Caching with aiocache | Accepted | 2026-03-21 |
2121
| [0007](0007-integration-only-test-strategy.md) | Integration-Only Test Strategy | Accepted | 2026-03-21 |
22+
| [0008](0008-layered-architecture.md) | Layered Architecture with FastAPI Dependency Injection | Accepted | 2026-04-02 |
23+
| [0009](0009-docker-and-compose-strategy.md) | Docker and Compose Strategy | Accepted | 2026-04-02 |

0 commit comments

Comments
 (0)