[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 # Architecture
## Layers ## Layer overview
``` ```
view → controller → repository → domain view → controller → repository → domain
@ -9,121 +9,103 @@ view → controller → repository → domain
(Pydantic) (SQLAlchemy) (Pydantic) (SQLAlchemy)
``` ```
### Domain (`fooder/domain/`) Each layer has a single responsibility and strict rules about what it may touch.
Pure SQLAlchemy ORM models. No business logic, no session awareness, no CRUD methods. Just column definitions and computed properties derived from loaded relationships.
### 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 ```python
await ctx.repo.product.get_by_id(product_id) await ctx.repo.product.get_by_id(product_id)
await ctx.repo.user.get_by_username(username) await ctx.repo.user_settings.get_by_user_id(user_id)
await ctx.repo.product.list(q=q, limit=10)
``` ```
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 ### Controller — write logic only
async with ctx.repo.transaction(): 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.
await SomeController.create(ctx, data)
await OtherController(ctx, obj).update(other_data)
# commits on exit, rolls back on any exception
```
### Controller (`fooder/controller/`) - Classmethods (`create`) construct and persist new objects
Business logic for write operations only. Controllers: - Instance methods (`update`) mutate existing ones
- Hold a domain object (`ModelController[T].obj`) obtained before entering a transaction - One controller per domain model — no cross-model orchestration inside a controller
- Mutate that object and call `repo.update()`
- Never commit — the view owns the transaction boundary
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/`) | Operation | Pattern |
Thin FastAPI route handlers. Responsible for: |-----------|---------|
- Accepting and validating HTTP input (via Pydantic models) | Read (GET) | `view → repo` directly, no controller, no transaction |
- Returning HTTP responses | Write (POST/PATCH/DELETE) | `view → controller` inside `async with ctx.repo.transaction()` |
- Owning the transaction boundary for write operations
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 | ### Model — shape only
|-----------|------| Pydantic schemas in three roles:
| Read (GET/list) | view → repository directly | - `*Model` — response serialization, `from_attributes=True`
| Write (POST/PATCH/DELETE) | view → controller, wrapped in `transaction()` | - `*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/`) 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.
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
--- ---
## Context ## 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 Two dependency variants: `ContextDependency` (public endpoints) and `AuthContextDependency` (protected endpoints — decodes JWT, loads user).
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
--- ---
## 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 | | Mixin | What it adds |
|-----------|------| |-------|-------------|
| `NotFound` | 404 | | `CommonMixin` | `id`, `version` (optimistic lock), `created_at`, `last_changed`, auto-tablename |
| `Unauthorized` | 401 | | `SoftDeleteMixin` | `deleted_at`; repo filters these automatically |
| `InvalidValue` | 400 | | `PasswordMixin` | `hashed_password`, `set_password()`, `verify_password()` |
| `Conflict` | 409 | | `EntryMacrosMixin` | Scales product macros by `grams / 100` — for `Entry`, `PresetEntry` |
| `AggregateMacrosMixin` | Sums macros across `.entries` — for `Meal`, `Preset` |
FastAPI's exception handler in `app.py` catches all `ApiException` instances and returns the appropriate JSON response. No exception handling in views or controllers.
--- ---
## Auth Flow ## Pydantic type aliases
**Login:** `POST /api/token` Shared field constraints are defined once as `Annotated` aliases and imported — never copy-pasted inline.
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
**Authenticated request:** Any endpoint using `AuthContextDependency` `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.
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
**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 ## Testing
- All tests use a real SQLite in-memory database (no mocks) - Real SQLite in-memory DB — no mocks for the database
- `db_session` fixture wraps each test in a transaction that is rolled back after the test — no cleanup needed - Each test is wrapped in a savepoint that rolls back after — no teardown needed
- `auth_client` fixture provides an `AsyncClient` with a valid Bearer token pre-set - `auth_client` fixture provides a pre-authenticated `AsyncClient`
- `product` fixture creates a product via `ProductController` within a transaction - Test data is created via controllers inside `ctx.repo.transaction()` — same path as production
- External I/O (e.g. `product_finder.find`) is monkeypatched