fooder-api/ARCHITECTURE.md

111 lines
4.8 KiB
Markdown

# 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