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=Truefor 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,RefreshTokenencode/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:
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, raisesUnauthorizedon 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
UserController.session_startverifies username + password → raisesUnauthorizedon failuregenerate_token_pair(user.id, now)returns a stateless JWT access + refresh token pair
Authenticated request: Any endpoint using AuthContextDependency
- Bearer token decoded via
AccessToken.decode() - User loaded from DB by
user_idfrom token payload Context.userset — raisesUnauthorizedif missing
Token refresh: POST /api/token/refresh
RefreshToken.decode(token)validates signature and expiry- 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_sessionfixture wraps each test in a transaction that is rolled back after the test — no cleanup neededauth_clientfixture provides anAsyncClientwith a valid Bearer token pre-setproductfixture creates a product viaProductControllerwithin a transaction