From 45c0a91e1e55416f3f21916f59cbdcf81a406cff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Piotr=20Doma=C5=84ski?= Date: Tue, 7 Apr 2026 17:45:20 +0200 Subject: [PATCH] [architecture] claude summed it up --- ARCHITECTURE.md | 142 +++++++++++++++++++++--------------------------- 1 file changed, 62 insertions(+), 80 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 4359469..bd2ef11 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1,6 +1,6 @@ # Architecture -## Layers +## Layer overview ``` view → controller → repository → domain @@ -9,121 +9,103 @@ view → controller → repository → domain (Pydantic) (SQLAlchemy) ``` -### Domain (`fooder/domain/`) -Pure SQLAlchemy ORM models. No business logic, no session awareness, no CRUD methods. Just column definitions and computed properties derived from loaded relationships. +Each layer has a single responsibility and strict rules about what it may touch. -### Repository (`fooder/repository/`) -All database access lives here. `RepositoryBase` provides protected `_get` / `_list` / `_delete` / `create` / `update` primitives. Concrete repositories (`ProductRepository`, `UserRepository`) expose intention-revealing named methods: +--- + +## The rules + +### Domain — data only +SQLAlchemy ORM models. Column definitions and computed properties derived from already-loaded relationships. No business logic, no session, no CRUD methods. If it needs a DB call, it doesn't belong here. + +### Repository — DB access only +All queries live here. The base class (`RepositoryBase`) provides `_get`, `_list`, `_delete`, `create`, `update`. Concrete repositories expose named, intention-revealing methods: ```python await ctx.repo.product.get_by_id(product_id) -await ctx.repo.user.get_by_username(username) -await ctx.repo.product.list(q=q, limit=10) +await ctx.repo.user_settings.get_by_user_id(user_id) ``` -No SQLAlchemy expressions (`ColumnElement`) ever leave the repository layer. Callers never know about the ORM. +**No SQLAlchemy expressions ever leave this layer.** Callers never import `select`, `where`, or any ORM primitive. -`Repository` aggregates all sub-repositories and owns transaction management via an async context manager: +Soft-delete is handled transparently: `_build_select` auto-excludes rows with `deleted_at`, and `_delete` soft-deletes instead of hard-deletes for models that have that column. -```python -async with ctx.repo.transaction(): - await SomeController.create(ctx, data) - await OtherController(ctx, obj).update(other_data) -# commits on exit, rolls back on any exception -``` +### Controller — write logic only +Business logic for **mutations only**. Controllers hold a domain object (`self.obj`), mutate it, and call `repo.update()`. They never commit — the view owns the transaction boundary. -### Controller (`fooder/controller/`) -Business logic for write operations only. Controllers: -- Hold a domain object (`ModelController[T].obj`) obtained before entering a transaction -- Mutate that object and call `repo.update()` -- Never commit — the view owns the transaction boundary +- Classmethods (`create`) construct and persist new objects +- Instance methods (`update`) mutate existing ones +- One controller per domain model — no cross-model orchestration inside a controller -Classmethods (`create`) construct and persist a new domain object. Instance methods (`update`) mutate an existing one. +### View — HTTP boundary only +Thin FastAPI handlers. Own the transaction boundary for writes. Follow **CQS strictly**: -### View (`fooder/view/`) -Thin FastAPI route handlers. Responsible for: -- Accepting and validating HTTP input (via Pydantic models) -- Returning HTTP responses -- Owning the transaction boundary for write operations +| Operation | Pattern | +|-----------|---------| +| Read (GET) | `view → repo` directly, no controller, no transaction | +| Write (POST/PATCH/DELETE) | `view → controller` inside `async with ctx.repo.transaction()` | -Follows **CQS (Command/Query Separation)**: +No business logic in views. No direct domain mutation. If a write touches multiple models, that's a command (see below). -| Operation | Path | -|-----------|------| -| Read (GET/list) | view → repository directly | -| Write (POST/PATCH/DELETE) | view → controller, wrapped in `transaction()` | +### Model — shape only +Pydantic schemas in three roles: +- `*Model` — response serialization, `from_attributes=True` +- `*CreateModel` — POST input validation +- `*UpdateModel` — PATCH input validation, **all fields optional** -Reads have no side effects and need no transaction or controller indirection. +Input constraints live here via `Annotated` type aliases — not in the domain, not in controllers. Shared aliases are defined in `model/base.py` and imported where needed. -### Model (`fooder/model/`) -Pydantic schemas. Three roles: -- `*Model` — response serialization (`from_attributes=True` for ORM→Pydantic) -- `*CreateModel` — input validation for POST endpoints -- `*UpdateModel` — input validation for PATCH endpoints (all fields optional) +--- -Models contain input-level logic (field constraints via `Field(ge=0, le=100)`, computed properties like `resolved_calories`) but no database awareness. +## CQS and commands -### Utils (`fooder/utils/`) -Stateless utilities with no layer dependencies: -- `jwt.py` — `Token`, `AccessToken`, `RefreshToken` encode/decode; `generate_token_pair()` -- `calories.py` — `calculate_calories()` -- `datetime.py` — `utc_now()` -- `password_helper.py` — bcrypt wrapper +Reads go straight from view to repository. Writes go through a controller. When a write spans multiple domain models (e.g. creating a diary copies goals from `UserSettings`), that logic belongs in a **command** — a standalone async function or class that coordinates multiple controllers and/or repositories within a single transaction. Commands are not yet a named module in this project but this is the intended pattern when needed. --- ## Context -`Context` is a per-request container injected into every view via FastAPI `Depends`: +Every request receives a `Context` object via FastAPI `Depends`. It carries: +- `ctx.repo` — the full repository aggregate (all sub-repos, transaction management) +- `ctx.user` — the authenticated `User` (raises `Unauthorized` if accessed on unauthenticated context) +- `ctx.clock` — injectable `utc_now` callable (makes time testable) -```python -class Context: - repo: Repository # all database access - clock: Callable # injectable clock (testable) - user: User # authenticated user (raises Unauthorized if not set) -``` - -Two dependency variants: -- `ContextDependency` — unauthenticated endpoints (token create/refresh) -- `AuthContextDependency` — decodes Bearer token, loads user, raises `Unauthorized` on failure +Two dependency variants: `ContextDependency` (public endpoints) and `AuthContextDependency` (protected endpoints — decodes JWT, loads user). --- -## Exception Handling +## Domain mixins -All domain exceptions inherit `ApiException` and declare their HTTP code: +Rather than deep inheritance, the domain uses flat mixins composed per model: -| Exception | HTTP | -|-----------|------| -| `NotFound` | 404 | -| `Unauthorized` | 401 | -| `InvalidValue` | 400 | -| `Conflict` | 409 | - -FastAPI's exception handler in `app.py` catches all `ApiException` instances and returns the appropriate JSON response. No exception handling in views or controllers. +| Mixin | What it adds | +|-------|-------------| +| `CommonMixin` | `id`, `version` (optimistic lock), `created_at`, `last_changed`, auto-tablename | +| `SoftDeleteMixin` | `deleted_at`; repo filters these automatically | +| `PasswordMixin` | `hashed_password`, `set_password()`, `verify_password()` | +| `EntryMacrosMixin` | Scales product macros by `grams / 100` — for `Entry`, `PresetEntry` | +| `AggregateMacrosMixin` | Sums macros across `.entries` — for `Meal`, `Preset` | --- -## Auth Flow +## Pydantic type aliases -**Login:** `POST /api/token` -1. `UserController.session_start` verifies username + password → raises `Unauthorized` on failure -2. `generate_token_pair(user.id, now)` returns a stateless JWT access + refresh token pair +Shared field constraints are defined once as `Annotated` aliases and imported — never copy-pasted inline. -**Authenticated request:** Any endpoint using `AuthContextDependency` -1. Bearer token decoded via `AccessToken.decode()` -2. User loaded from DB by `user_id` from token payload -3. `Context.user` set — raises `Unauthorized` if missing +`model/base.py` holds generic aliases (`Macronutrient`, `Calories`). Domain-specific aliases (e.g. `MacronutrientPer100g`, `CaloriesPer100g` in `model/product.py`) are defined locally in the model file that owns them. -**Token refresh:** `POST /api/token/refresh` -1. `RefreshToken.decode(token)` validates signature and expiry -2. New token pair issued for `token.sub` — stateless rotation, no DB storage +--- + +## Exception handling + +All exceptions inherit `ApiException` with a class-level `HTTP_CODE`. The FastAPI exception handler in `app.py` is the only place that converts them to HTTP responses. Views and controllers raise, never catch. --- ## Testing -- All tests use a real SQLite in-memory database (no mocks) -- `db_session` fixture wraps each test in a transaction that is rolled back after the test — no cleanup needed -- `auth_client` fixture provides an `AsyncClient` with a valid Bearer token pre-set -- `product` fixture creates a product via `ProductController` within a transaction +- Real SQLite in-memory DB — no mocks for the database +- Each test is wrapped in a savepoint that rolls back after — no teardown needed +- `auth_client` fixture provides a pre-authenticated `AsyncClient` +- Test data is created via controllers inside `ctx.repo.transaction()` — same path as production +- External I/O (e.g. `product_finder.find`) is monkeypatched