fooder-api/ARCHITECTURE.md

4.7 KiB

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:

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:

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.pyToken, AccessToken, RefreshToken encode/decode; generate_token_pair()
  • calories.pycalculate_calories()
  • datetime.pyutc_now()
  • password_helper.py — bcrypt wrapper

Context

Context is a per-request container injected into every view via FastAPI Depends:

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