4.8 KiB
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:
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 authenticatedUser(raisesUnauthorizedif accessed on unauthenticated context)ctx.clock— injectableutc_nowcallable (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_clientfixture provides a pre-authenticatedAsyncClient- Test data is created via controllers inside
ctx.repo.transaction()— same path as production - External I/O (e.g.
product_finder.find) is monkeypatched