# Architecture ## Layers ``` view → controller → repository → domain ↑ ↑ model 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. ### 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: ```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) ``` No SQLAlchemy expressions (`ColumnElement`) ever leave the repository layer. Callers never know about the ORM. `Repository` aggregates all sub-repositories and owns transaction management via an async context manager: ```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 (`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 a new domain object. Instance methods (`update`) mutate an existing one. ### 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 Follows **CQS (Command/Query Separation)**: | Operation | Path | |-----------|------| | Read (GET/list) | view → repository directly | | Write (POST/PATCH/DELETE) | view → controller, wrapped in `transaction()` | Reads have no side effects and need no transaction or controller indirection. ### 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. ### 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 --- ## Context `Context` is a per-request container injected into every view via FastAPI `Depends`: ```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 --- ## Exception Handling All domain exceptions inherit `ApiException` and declare their HTTP code: | 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. --- ## Auth Flow **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 **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 **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 --- ## 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