fooder-api/ARCHITECTURE.md

4.9 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 authenticated User (raises Unauthorized if accessed on unauthenticated context)
  • ctx.clock — injectable utc_now callable (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_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

Misc rules

  • Use absolute imports