# Architecture ## Layer overview ``` view → controller → repository → domain ↑ ↑ model domain (Pydantic) (SQLAlchemy) ``` Each layer has a single responsibility and strict rules about what it may touch. --- ## 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_settings.get_by_user_id(user_id) ``` **No SQLAlchemy expressions ever leave this layer.** Callers never import `select`, `where`, or any ORM primitive. 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. ### 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. - 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 ### View — HTTP boundary only Thin FastAPI handlers. Own the transaction boundary for writes. Follow **CQS strictly**: | Operation | Pattern | |-----------|---------| | Read (GET) | `view → repo` directly, no controller, no transaction | | Write (POST/PATCH/DELETE) | `view → controller` inside `async with ctx.repo.transaction()` | No business logic in views. No direct domain mutation. If a write touches multiple models, that's a command (see below). ### 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** 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. --- ## CQS and commands 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 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) Two dependency variants: `ContextDependency` (public endpoints) and `AuthContextDependency` (protected endpoints — decodes JWT, loads user). --- ## Domain mixins Rather than deep inheritance, the domain uses flat mixins composed per model: | 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` | --- ## Pydantic type aliases Shared field constraints are defined once as `Annotated` aliases and imported — never copy-pasted inline. `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. --- ## 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 - 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 ## Misc rules - Use absolute imports