[architecture] claude summed it up

This commit is contained in:
Piotr Domański 2026-04-07 17:45:20 +02:00
parent 57e1732ef1
commit 45c0a91e1e

View file

@ -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