[architecture] claude summed it up
This commit is contained in:
parent
57e1732ef1
commit
45c0a91e1e
1 changed files with 62 additions and 80 deletions
142
ARCHITECTURE.md
142
ARCHITECTURE.md
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue