Development Conventions¶
Code Style¶
Python¶
- Formatter: Black (line length: 88)
- Linter: Ruff
- Type checker: mypy (strict mode)
- Import sorting: isort
# Good example
from uuid import UUID
from pydantic import BaseModel, ConfigDict, Field
class RecipeId(BaseModel):
"""Recipe identifier value object."""
model_config = ConfigDict(frozen=True)
value: UUID = Field(..., description="Unique recipe identifier")
Type Hints¶
- All public functions must have type hints
- Use
|for unions (Python 3.10+) - Use
list[T],dict[K, V]instead ofList,Dict
# Good
def get_recipe(recipe_id: RecipeId) -> Recipe | None:
...
# Bad
def get_recipe(recipe_id): # No type hints
...
Naming Conventions¶
| Element | Convention | Example |
|---|---|---|
| Modules | snake_case | recipe_aggregate.py |
| Classes | PascalCase | ProductionOrder |
| Functions | snake_case | calculate_allergens() |
| Variables | snake_case | available_quantity |
| Constants | SCREAMING_SNAKE | MAX_BOM_DEPTH |
| Private | _underscore | self._ingredients |
Module Layout (canonical)¶
Every module follows the same skeleton. Default to folder-per-layer-piece even when there is only one entity — uniformity across the seven modules beats local minimalism.
src/modules/<m>/
├── domain/
│ ├── entities/
│ │ ├── __init__.py
│ │ ├── recipe.py # one entity per file
│ │ └── bom_line.py
│ ├── value_objects/
│ │ ├── __init__.py
│ │ ├── recipe_status.py
│ │ └── yield_quantity.py
│ ├── repositories/ # ABCs only
│ │ ├── recipe_repository.py
│ │ └── bom_line_repository.py
│ ├── ports/ # outbound interfaces to other modules
│ │ ├── warehouse_stock_port.py
│ │ └── crm_orderable_port.py
│ ├── events.py # PascalCase DomainEvent subclasses pinned to this module
│ └── exceptions.py
├── application/
│ ├── commands/
│ │ ├── create_recipe.py # CreateRecipeCommand + CreateRecipeResult + CreateRecipeHandler
│ │ └── publish_recipe_version.py
│ ├── queries/
│ │ ├── get_recipe.py # GetRecipeQuery + RecipeReadModel + GetRecipeHandler
│ │ └── list_recipes.py
│ └── dtos.py # ONLY types reused by multiple handlers; per-use-case results live with their handler
├── infrastructure/
│ ├── models/ # one SQLAlchemy model per file
│ │ ├── __init__.py # re-exports every model so it lands on Base.metadata
│ │ ├── recipe.py
│ │ └── bom_line.py
│ ├── repositories/ # one SQLAlchemy repo impl per file
│ │ ├── sqlalchemy_recipe_repository.py
│ │ └── sqlalchemy_bom_line_repository.py
│ └── event_publisher.py
└── api/
├── router.py # composes routers/ → exposes one <module>_router
├── routers/ # one APIRouter per entity / use-case grouping
│ ├── recipes.py
│ └── recipe_versions.py
├── dependencies.py # Annotated dependency aliases for this module
└── schemas/
├── __init__.py
├── shared.py # RecipeRead, IdResponse, PageMeta — used by many routes
├── create_recipe.py # CreateRecipeRequest / CreateRecipeResponse
└── publish_recipe_version.py
Naming: snake_case files; class name in PascalCase. The file entities/recipe.py contains class Recipe. The file commands/create_recipe.py contains both CreateRecipeCommand and CreateRecipeHandler. The file schemas/create_recipe.py contains both CreateRecipeRequest and CreateRecipeResponse.
CQS Pattern (Command Query Separation)¶
All application-layer operations are either a Command (writes state, has side effects) or a Query (reads state, no side effects). The naming makes this immediately visible.
Command Handler¶
Commands change state. They save to DB and emit domain events. Named {Action}{Noun}Command + {Action}{Noun}Handler.
# application/commands/create_recipe.py
from dataclasses import dataclass
from uuid import UUID
from modules.recipe.domain.entities.recipe import Recipe
from modules.recipe.domain.exceptions import RecipeCodeAlreadyExistsError
from modules.recipe.domain.repositories.recipe_repository import RecipeRepository
from src.shared.observability import get_logger, traced_handler
logger = get_logger(__name__)
@dataclass
class CreateRecipeCommand:
name: str
code: str
category: str
tenant_id: UUID
created_by: UUID
@dataclass
class CreateRecipeResult:
recipe_id: UUID
class CreateRecipeHandler:
def __init__(self, recipe_repo: RecipeRepository) -> None:
self._recipe_repo = recipe_repo
@traced_handler
async def handle(self, command: CreateRecipeCommand) -> CreateRecipeResult:
# load → validate → call domain → save → emit → return
existing = await self._recipe_repo.get_by_code(command.code, command.tenant_id)
if existing:
raise RecipeCodeAlreadyExistsError(command.code)
recipe = Recipe.create(
name=command.name,
code=command.code,
category=command.category,
tenant_id=command.tenant_id,
created_by=command.created_by,
)
await self._recipe_repo.save(recipe)
logger.info("recipe_created", event="recipe_created", entity_id=str(recipe.id))
return CreateRecipeResult(recipe_id=recipe.id)
@traced_handler opens a span named {module}.{handler_class} and binds module / command keys to structlog.contextvars for the duration of the call. Use logger = get_logger(__name__) at the top of every module so each log record carries its fully-qualified module path under the logger key — searchable in SigNoz. Query handlers use the same decorator; on the happy path a Query Handler typically logs nothing — the HTTP span already records duration and status.
File-layout rule: the Command, Result, and Handler for one use case live together in application/commands/{use_case}.py (and the equivalent for queries under application/queries/). application/dtos.py is reserved for application-layer types that are genuinely shared across two or more handlers — for example a read model returned by both GetItem and ListItems. Per-use-case Result / ReadModel dataclasses do not go in dtos.py; keep them next to the handler that produces them so the entire use case is one file to read.
Query Handler¶
Queries only read. No saves, no events, no domain method calls that mutate state. Named {Get/List}{Noun}Query + {Get/List}{Noun}Handler.
# application/queries/get_recipe.py
from dataclasses import dataclass
from uuid import UUID
from modules.recipe.domain.exceptions import RecipeNotFoundError
from modules.recipe.domain.repositories.recipe_repository import RecipeRepository
from src.shared.observability import traced_handler
@dataclass
class GetRecipeQuery:
recipe_id: UUID
tenant_id: UUID
@dataclass
class RecipeReadModel:
recipe_id: UUID
name: str
code: str
status: str
current_version: int
class GetRecipeHandler:
def __init__(self, recipe_repo: RecipeRepository) -> None:
self._recipe_repo = recipe_repo
@traced_handler
async def handle(self, query: GetRecipeQuery) -> RecipeReadModel:
recipe = await self._recipe_repo.get_by_id(query.recipe_id)
if recipe is None:
raise RecipeNotFoundError(query.recipe_id)
return RecipeReadModel(
recipe_id=recipe.id,
name=recipe.name,
code=recipe.code,
status=recipe.status.value,
current_version=recipe.current_version,
)
Domain-Driven Design¶
Aggregate Rules¶
- One repository per aggregate root
- Transaction boundary = aggregate boundary
- Keep aggregates small - only include what's needed for consistency
- Reference other aggregates by ID, not by object reference
# Good - reference by ID
class ProductionOrder:
recipe_id: RecipeId # Not Recipe object
recipe_version: int
# Bad - holding reference
class ProductionOrder:
recipe: Recipe # Don't do this
Value Objects¶
- Immutable - use
frozen=Truein Pydantic - Equality by value - two VOs with same values are equal
- No identity - only value matters
class Quantity(BaseModel):
model_config = ConfigDict(frozen=True)
value: Decimal
unit: UnitOfMeasure
def __add__(self, other: "Quantity") -> "Quantity":
if self.unit != other.unit:
raise UnitMismatchError
return Quantity(value=self.value + other.value, unit=self.unit)
Domain Events¶
- Named in past tense:
RecipeCreated,OrderCompleted - Contain all data needed by consumers
- Include tenant_id for routing
class RecipeVersionPublished(DomainEvent):
event_type: Literal["RecipeVersionPublished"] = "RecipeVersionPublished"
recipe_id: UUID
version: int
author_id: UUID
published_at: datetime
Guard Placement — Where Invariants Live¶
Every command handler does some checking before it mutates state. Where a given check belongs is not a style decision — putting it in the wrong layer either weakens the model (entity stops being a source of truth) or duplicates logic that drifts over time.
Three categories, three layers:
| Category | Lives in | Why | Example |
|---|---|---|---|
| Pure state-machine invariant | Domain entity | The aggregate is the single source of truth for its own state transitions. A direct caller (test, future use case, REPL) must not be able to drive the entity into an illegal state. | "Cannot deactivate an already-INACTIVE item." Item.deactivate raises InvalidItemStateTransitionError itself. |
| Actor-aware authorization | Handler | The entity has no concept of "who is acting". Authorization that depends on the caller (role, system-actor distinction, ownership) belongs above the domain. | "Tenants cannot mutate system items." Handler checks if item.is_system: raise SystemItemIsReadOnlyError before any domain call. |
| Cross-aggregate / cross-module guard | Handler | Other-aggregate counts, uniqueness checks, foreign-key existence, port-mediated remote checks. The entity cannot see anything outside itself. | "Cannot deactivate an item that still has stock." Handler calls WarehouseItemQueryPort.get_stock_quantity(...) before Item.deactivate(...). |
Handlers MUST NOT duplicate pure state-machine invariants. The entity already enforces them; a duplicate check in the handler creates two sources of truth that will drift. If you find yourself writing if item.status is X: raise Y in a handler, and Item.method() already raises Y for that case, delete the handler-level check and let the domain raise.
The one exception — error-precedence short-circuit. A handler MAY pre-check a state-machine condition if and only if BOTH:
- There are subsequent expensive operations (cross-module port calls, large reads) that would run before the domain method, and
- The error semantics require the state-machine error to take precedence over those other failures.
When this exception applies, document it with an inline comment so the next reader knows why the duplication is intentional.
# application/commands/deactivate_item.py — EXCEPTION applies
async def handle(self, command: DeactivateItemCommand) -> DeactivateItemResult:
item = await self._item_repo.get_by_id(command.item_id, command.tenant_id)
if item is None:
raise ItemNotFoundError(...)
if item.is_system: # actor-aware authorization — handler
raise SystemItemIsReadOnlyError(...)
# State-machine pre-check — duplicates Item.deactivate's invariant on purpose:
# without it, an already-INACTIVE item that also has stock would surface as
# 409 ITEM_HAS_STOCK_REMAINING instead of the correct 400 INVALID_ITEM_STATE_TRANSITION.
if item.status is ItemStatus.INACTIVE:
raise InvalidItemStateTransitionError(...)
stock = await self._warehouse_port.get_stock_quantity(...) # expensive cross-module call
if stock > 0:
raise ItemHasStockRemainingError(...)
# ... more cross-module checks ...
item.deactivate(deactivated_by=command.deactivated_by)
await self._item_repo.save(item)
await self._event_publisher.publish(_build_item_deactivated(item))
return DeactivateItemResult(item_id=item.id)
# application/commands/reactivate_item.py — EXCEPTION does NOT apply
async def handle(self, command: ReactivateItemCommand) -> ReactivateItemResult:
item = await self._item_repo.get_by_id(command.item_id, command.tenant_id)
if item is None:
raise ItemNotFoundError(...)
# No cross-module checks follow → no precedence problem → no handler-level
# state-machine guard. Item.reactivate() raises InvalidItemStateTransitionError
# for already-ACTIVE items; that's the single source of truth.
item.reactivate(reactivated_by=command.reactivated_by)
await self._item_repo.save(item)
await self._event_publisher.publish(_build_item_reactivated(item))
return ReactivateItemResult(item_id=item.id)
Test placement follows guard placement:
- State-machine invariants → domain unit test (
tests/unit/{module}/domain/test_{entity}.py). The handler test does not re-assert them — that would test the same thing twice and couple handler tests to domain internals. - Actor-aware authorization → handler unit test (
tests/unit/{module}/application/test_{command}.py). - Cross-aggregate guards → handler unit test (with fake ports / repos).
- The error-precedence exception, when it applies → one handler unit test asserting the precedence (i.e. configure two failures at once and assert the state-machine one wins). Don't repeat the domain's own happy/sad paths — just the ordering.
Repository Pattern¶
- Interface in domain layer (Protocol/class)
- Implementation in infrastructure layer
- Only for aggregate roots
# domain/repositories/recipe_repository.py
class RecipeRepository(Protocol):
async def get_by_id(self, recipe_id: RecipeId) -> Recipe | None: ...
async def save(self, recipe: Recipe) -> None: ...
# infrastructure/persistence/repositories/sqlalchemy_recipe_repository.py
class SqlAlchemyRecipeRepository(RecipeRepository):
def __init__(self, session: AsyncSession):
self._session = session
async def get_by_id(self, recipe_id: RecipeId) -> Recipe | None:
model = await self._session.get(RecipeModel, recipe_id.value)
return RecipeConverter.to_domain(model) if model else None
Testing¶
The authoritative source for test layout, factory patterns, fixture reference,
parametrization rules, and per-layer coverage targets is
docs/testing/testing-conventions.md. Read
it in full before writing any test.
Highlights for cross-cutting decisions made elsewhere in this doc:
- Tests live in the same PR as the code they cover — never in a follow-up.
- Four test layers mirror the four DDD layers (domain unit, application unit, infrastructure integration, API integration). Each layer uses the lightest infrastructure that still proves the behaviour — domain tests touch no DB, application tests use in-memory repos, infrastructure tests use a real Postgres, API tests go end-to-end through FastAPI.
- Parametrize related variants (
@pytest.mark.parametrizewithids=[...]) when the assertion logic is identical and only the inputs differ — see testing-conventions.md § Parametrize Related Test Variants. Apply the rule of three: when you write the third near-identical test, collapse them. - Class-per-behaviour test organisation: one
Test{Command}class per command/query handler, oneTest{Endpoint}class per route. Class-scoped fixtures provide the SUT. - Outbox assertion is mandatory at the API integration layer for every state-mutating endpoint — see § Transactional Outbox below for the contract.
Per-layer coverage targets, factory style, fixture reference, and worked examples for each layer all live in the testing-conventions doc.
Git Conventions¶
Branch Naming¶
feat/REC-123-multi-level-bom
fix/WH-456-fefo-sorting
docs/api-documentation
refactor/PROD-789-order-workflow
test/REC-456-recipe-tests
Commit Messages (Conventional Commits)¶
Types:
- feat - New feature
- fix - Bug fix
- docs - Documentation
- refactor - Code refactoring
- test - Tests
- chore - Maintenance
Examples:
feat(recipe): add multi-level BOM support
- Add RecipeIngredient with semi-finished flag
- Implement BOM traversal for cost calculation
- Add max depth validation (5 levels)
Closes REC-123
fix(warehouse): correct FEFO sorting for expired batches
Expiration date was not considered when lots had same received date.
Fixes WH-456
Pull Requests¶
Title: Same format as commit message
Template:
## Summary
Brief description of changes.
## Changes
- Bullet list of key changes
## Testing
- [ ] Unit tests added/updated
- [ ] Integration tests pass
- [ ] Manual testing done
## Related
- Closes #123
API Conventions¶
Schemas vs Commands vs Queries¶
Three distinct types — each lives at a different layer:
| Type | Layer | Base class | Purpose |
|---|---|---|---|
CreateRecipeRequest |
API | Pydantic BaseModel |
HTTP input validation, OpenAPI docs |
CreateRecipeResponse |
API | Pydantic BaseModel |
HTTP response shape, OpenAPI docs |
CreateRecipeCommand |
Application | @dataclass |
Carries write intent into the handler |
GetRecipeQuery |
Application | @dataclass |
Carries read intent into the handler |
CreateRecipeResult / RecipeReadModel |
Application | @dataclass |
Handler return value, no HTTP semantics |
The API route maps between layers:
# api/router.py
@router.post("/recipes", status_code=201)
async def create_recipe(
body: CreateRecipeRequest, # ← Pydantic schema
handler: CreateRecipeHandler = Depends(...),
current_user: CurrentUser = Depends(get_current_user),
) -> CreateRecipeResponse: # ← Pydantic schema
result = await handler.handle(
CreateRecipeCommand( # ← Command dataclass
name=body.name,
code=body.code,
category=body.category,
tenant_id=current_user.tenant_id,
created_by=current_user.user_id,
)
)
return CreateRecipeResponse(recipe_id=result.recipe_id)
Why not just use Pydantic everywhere? Commands and Queries must be testable without FastAPI. Call handler.handle(CreateRecipeCommand(...)) directly in integration tests — no HTTP client, no Pydantic validation overhead. The schema layer stays strictly at the HTTP boundary.
Naming rule: If a class name ends in Command, Query, or Result/ReadModel — it is a plain dataclass in the application layer. If it ends in Request or Response — it is a Pydantic model in the API layer.
Schema field typing — enum-domain fields must use the enum class (mandatory)¶
Any Pydantic field — on either a request or response schema — whose semantic domain is an enum must be typed with that enum class. Bare str is banned for those fields. This applies to every enum we expose: domain StrEnums (ItemType, ItemStatus, StorageUnit, …) and hand-written Literal[...] unions (e.g. sort tokens, the tri-state status filter).
# ❌ BREAKS FE codegen — OpenAPI emits "type": "string"
class ItemDetailResponse(BaseModel):
item_type: str
storage_unit: str
status: str
# ✅ CORRECT — OpenAPI emits $ref to the enum component
class ItemDetailResponse(BaseModel):
item_type: ItemType
storage_unit: StorageUnit
status: ItemStatus
Why this is a hard rule. Both FE codegen passes depend on the enum being visible in the spec:
- Orval → TS types. Field typed
strbecomesstring; field typed with the enum becomes the discriminated union ('active' | 'inactive'). Every FE component that branches onitem.status === 'active'only narrows correctly when the field is the union —stringnever narrows and the branch silently falls through to the fallback render. - Orval → MSW faker mocks. Field typed
strbecomesfaker.string.alpha({length:{min:10,max:20}})— the dev mock returns nonsense like"qweRTYuiopASD"that no===branch ever matches. Enum-typed fields becomefaker.helpers.arrayElement([...])and the FE renders the same code paths inpnpm dev:mswas in production.
The runtime BE values are already constrained to the enum — this rule is about the advertised type, not the actual type.
status gotcha — domain enum on responses, tri-state filter on query params. A list endpoint's query model uses ItemStatusFilter (active | inactive | all) for ?status= because "all" is a valid request. The matching response schemas use the domain enum ItemStatus (active | inactive) — the response can never return all. The Literal["active","inactive","all"] filter rule lives in § Status filter — tri-state enum further down; the response-side rule is here.
Enforcement. tests/unit/shared/api/test_openapi_enum_consistency.py walks the generated OpenAPI spec and fails if any property name is typed as bare string in one schema while being $ref'd to an enum component in another. New endpoints that violate the rule break CI immediately — no separate review check needed.
Router Composition¶
Three tiers, top-down:
- Entity / use-case routers live in
src/modules/<m>/api/routers/. Each file declares oneAPIRouterwith a prefix matching its resource (e.g.items_router = APIRouter(prefix="/items", tags=["item-catalog"])). - Module router in
src/modules/<m>/api/router.pycomposes those into a single<module>_router = APIRouter(), including each entity router with<module>_router.include_router(items_router). - Global router in
src/shared/api/router.pydeclaresapi_router = APIRouter(prefix="/api/v1")andinclude_routers every module router.
src/main.py mounts exactly two routers: health_router and the global api_router. Module-level changes never touch main.py.
Dependency Injection¶
Every dependency that appears in more than one route must be exposed as an Annotated[T, Depends(...)] alias. This keeps route signatures readable and ensures the dependency is wired the same way everywhere.
Cross-module aliases live in src/shared/api/deps.py:
from typing import Annotated
from fastapi import Depends
from sqlalchemy.ext.asyncio import AsyncSession
from src.shared.auth.current_user import CurrentUser, get_current_user
from src.shared.db.session import get_session
DbSession = Annotated[AsyncSession, Depends(get_session)]
CurrentUserDep = Annotated[CurrentUser, Depends(get_current_user)]
Module aliases live in src/modules/<m>/api/dependencies.py:
from typing import Annotated
from fastapi import Depends
ItemRepoDep = Annotated[ItemRepository, Depends(get_item_repository)]
ItemGroupRepoDep = Annotated[ItemGroupRepository, Depends(get_item_group_repository)]
Usage in a route:
@router.post("", status_code=201)
async def create_item(
body: CreateItemRequest,
repo: ItemRepoDep,
user: CurrentUserDep,
) -> CreateItemResponse:
...
Role-gated endpoints use the permission-based guard from src.shared.auth — Depends(require_permission(Permission.X)) — rather than pre-built combination aliases. See § Authorization below for the full pattern.
URL Structure¶
HTTP Methods¶
| Method | Use |
|---|---|
| GET | Read (idempotent) |
| POST | Create |
| PUT | Full update |
| PATCH | Partial update |
| DELETE | Delete |
Response Codes¶
| Code | Use |
|---|---|
| 200 | Success (GET, PUT, PATCH) |
| 201 | Created (POST) |
| 204 | No content (DELETE) |
| 400 | Validation error |
| 401 | Unauthorized |
| 403 | Forbidden |
| 404 | Not found |
| 409 | Conflict (e.g., duplicate) |
| 422 | Unprocessable entity |
| 500 | Server error |
Pagination¶
Offset-based, 1-indexed page numbers, with a stable snapshot_at returned in
the response envelope to defend against duplicates when rows are inserted
between page fetches. See § List Endpoints below for the full design
(pagination, filtering, sorting, search, stats banner, FE coordination).
GET /api/v1/recipes?page=1&page_size=20
Response:
{
"items": [...],
"total": 100,
"page": 1,
"page_size": 20,
"pages": 5,
"snapshot_at": "2026-05-16T12:34:56Z"
}
Error Response¶
The runtime envelope every non-2xx response carries. Routes declare which of these envelopes are possible via the errors_for() helper — see § Error Response Declarations below.
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Validation failed",
"details": [
{"field": "name", "message": "Name is required"}
]
}
}
Error Response Declarations¶
### Error Response above describes the runtime envelope. This section describes the OpenAPI contract — how each route advertises its possible error responses so the FE (and any other consumer) can read them from the spec instead of grepping domain/exceptions.py.
Rule: every route declares its possible errors via errors_for(...) from src.shared.api.error_responses:
from src.shared.api.error_responses import errors_for
@items_router.patch(
"/{item_id}",
status_code=200,
response_model=EditItemResponse,
responses=errors_for(
domain=[EANAlreadyInUseError, SystemItemIsReadOnlyError, ShelfLifeDaysNotApplicableError],
not_found=ItemNotFoundError,
mutation=True,
optimistic=True,
patch=True,
permission=Permission.ITEM_UPDATE,
),
)
errors_for() builds a FastAPI responses= dict from three sources:
- Class-universal entries (always added). Every route can return
422 VALIDATION_ERRORand500 REPOSITORY_ERROR, so the helper emits them unconditionally. - Flag-implied entries (added when the flag is set). Each flag corresponds to a piece of shared machinery that fires the same exception across every route that opts in:
| Flag | Adds | Trigger |
|---|---|---|
authenticated=True (default) |
401 UNAUTHORIZED |
Any route that requires CurrentUser |
permission=Permission.X |
403 FORBIDDEN_INSUFFICIENT_PERMISSION |
Endpoint gated with require_permission(...) |
not_found=Exc |
404 with Exc.code |
Endpoint loads a specific aggregate that may not exist |
mutation=True |
422 IDEMPOTENCY_KEY_REQUIRED + 409 IDEMPOTENCY_KEY_REUSED_WITH_DIFFERENT_PAYLOAD |
Endpoint decorated with @idempotent |
optimistic=True |
409 CONCURRENT_MODIFICATION |
Endpoint mutates an OptimisticLockMixin aggregate |
patch=True |
400 IMMUTABLE_FIELD_CANNOT_BE_CHANGED |
PATCH endpoint whose schema inherits PatchRequestModel |
- Domain entries (
domain=[...]) — the per-route list ofDomainErrorsubclasses the handler can raise. Each class is added at its ownhttp_status.
Grouping by status. Multiple exception classes that share the same HTTP status (e.g. several 409s) collapse into a single OpenAPI response entry whose examples field carries one named example per exception (keyed by code). Swagger UI shows the full list of possible codes inline.
Routes with no domain errors still call errors_for() so the class-universal tier and the flag-implied entries are wired correctly — "drop the kwarg" is never a valid shortcut.
Identity-style endpoints opt out of 401. Login / token-refresh / register endpoints don't go through any auth dependency, so they cannot return the generic UNAUTHORIZED. They pass authenticated=False; any 401 they emit (e.g. bad credentials) is a domain error listed in domain=[...].
Backfill obligation. Every module's first PR — and every new route after that — must use errors_for(). The OpenAPI spec is the single FE-facing contract; routes that don't declare their errors silently break planning for downstream consumers.
The FE-side parser, i18n catalogue, and handleApiError helper that consume this contract are tracked separately (see grinsystem-front/docs/standards.md § Error Envelope).
List Endpoints¶
These rules apply to every GET /api/v1/{resource} collection endpoint — paginated list, search, filter, sort. They were established in GRI-86 and are mandatory for every list endpoint we ship. The shared building blocks live in src/shared/api/pagination.py, src/shared/api/sorting.py, and src/shared/api/search.py.
The standard is shaped around three constraints we don't relitigate per resource:
- The FE uses Orval to codegen
useListItems(query)hooks + Zod schemas from our OpenAPI spec. Every filter must survive that codegen — i.e. it must be a typed scalar query param, not a custom DSL string. RSQL,filter[x]=, JSON-encoded filters are all out. - TanStack Table needs random-access pagination + a total count. That rules out cursor pagination as the default. Offset wins.
- URL is the source of truth on the FE. Pagination, filters, sort, and snapshot are all in the URL search params. The "draft vs committed" state lives on the FE side (see
grinsystem-front/docs/standards.md); the BE only sees the committed shape.
Two companion read patterns sit alongside the paginated list and follow further down in this file: § Stats endpoints (filter-agnostic, tenant-wide aggregate counts) and § List Resource Options Endpoints (bounded-cardinality picker / combobox shapes). All three patterns are mandatory where they apply — they're not interchangeable, and shoehorning one into another (e.g. populating a picker from the paginated list) is an anti-pattern called out in each section.
One Query() parameter per list endpoint (mandatory)¶
A list endpoint exposes exactly one Pydantic Query() parameter: a single model that inherits PaginationParams and carries pagination + filters + sort. Do not add any second Query() parameter alongside it — neither another Pydantic model nor a bare scalar (e.g. a standalone sort).
Why this is a hard rule. FastAPI (≥ 0.115) flattens a Pydantic Query() model into individual, per-field query params only when it is the sole query parameter on the handler. The moment a second Query() param appears — another model or a scalar — FastAPI stops flattening and emits each model as a nested $ref query param marked required: true. Consequences:
- The endpoint 422s on flat query strings (
?page=1&status=active) and on no params at all — the wrapper object is reported "missing", so the per-field defaults never apply. - The OpenAPI contract advertises
required: trueobject params even though every field has a default, and the FE's Orval codegen emits unserializable nested params (?pagination=[object Object]).
The breakage is silent: code, types, and app startup all look fine — only a real request (or the FE) reveals it. Established in GRI-104.
# ❌ BROKEN — a second Query() param (another model OR a scalar) stops flattening
async def list_items(
pagination: Annotated[PaginationParams, Query()],
filters: Annotated[ItemListFilterParams, Query()], # second model
sort: Annotated[ItemSortToken | None, Query()] = None, # ...or just a scalar
): ...
# ✅ CORRECT — one model carries pagination + filters + sort
async def list_items(
params: Annotated[ItemListQueryParams, Query()],
): ...
Depends() is not an escape hatch. Annotated[Model, Depends()] flattens even with multiple models/scalars, but it silently drops the extra="forbid" unknown-param rejection (?bogus=1 → 200 instead of 422), so typos and stale FE params pass through unnoticed. Always use Query() with the single-model rule.
Pagination + snapshot¶
The single query model inherits PaginationParams from src.shared.api.pagination, so it gets page / page_size / as_of / .offset / .resolve_snapshot() for free:
from typing import Annotated
from fastapi import APIRouter, Query
from src.shared.api.pagination import PaginatedResponse, PaginationParams
class ItemListQueryParams(PaginationParams):
# filters + sort fields — see § Filter parameters and § Sort
...
@router.get("/items")
async def list_items(
params: Annotated[ItemListQueryParams, Query()],
) -> PaginatedResponse[ItemListItem]:
snapshot = params.resolve_snapshot()
items, total = await handler.handle(... , snapshot, params.offset, params.page_size)
return PaginatedResponse.build(
items=[ItemListItem.model_validate(x) for x in items],
total=total,
params=params,
snapshot_at=snapshot,
)
Defaults: page=1, page_size=20, max page_size=100. Boundary violations surface as Pydantic 422.
The as_of snapshot is optional on the wire. If absent, the route resolves now() once per request via params.resolve_snapshot(), passes it down to the repo (which applies WHERE created_at <= :as_of), and echoes it back in snapshot_at. If present, it's echoed back unchanged. The FE writes snapshot_at to the URL on first navigation (navigate({ replace: true })) so subsequent page changes reuse the same snapshot — consistent row set + stable total. The FE drops as_of on Apply Filters / Apply Sort / explicit Refresh so the BE re-snapshots.
Scope of the snapshot is intentional:
as_ofconstrainscreated_atonly. Updates (updated_at,is_activeflips, name edits) flow through live — that's a feature; users want to see live status.- Soft-deletes during browsing: a row visible on page 1 may be filtered out of a refresh of page 1 because the
statusfilter excludes it;totalstays correct because the snapshot still counts the row. - For genuinely point-in-time reporting (regulatory, audit), build a dedicated history table — don't lean on
as_of.
Filter parameters — fields on the single query model¶
Filters are fields on the one query model, not a separate model. The model inherits PaginationParams (so extra="forbid" and the pagination fields come for free) and declares the per-resource filters + sort:
from typing import Literal
from uuid import UUID
from datetime import datetime
from pydantic import Field
from src.shared.api.pagination import PaginationParams
class ItemListQueryParams(PaginationParams):
# pagination (page / page_size / as_of) + extra="forbid" inherited
q: str | None = Field(default=None, max_length=200)
item_type: list[ItemType] = Field(default_factory=list)
status: Literal["active", "inactive", "all"] = "active" # see § Status filter
source: Literal["tenant", "system", "all"] = "tenant"
item_group_id: UUID | None = None
created_after: datetime | None = None
created_before: datetime | None = None
sort: ItemSortToken | None = None # see § Sort
Repeated scalar params naturally become lists: ?item_type=ingredient&item_type=packaging.
extra="forbid" (inherited from PaginationParams) rejects unknown query params with 422 — typos and stale FE code don't get silently dropped. Do not split filters into a second Query() model or pull any field out into a standalone scalar param — that re-triggers the flattening bug (see § One Query() parameter per list endpoint).
Filter-agnostic, tenant-wide aggregate counts belong on the companion /stats endpoint, not on this filter-aware list response — see § Stats endpoints.
Status filter — tri-state enum, implicit default = working set¶
Every list endpoint that has an active/archived/closed distinction uses a tri-state enum, not a bool:
status: Literal["active", "inactive", "all"] = "active" # items
status: Literal["open", "closed", "all"] = "open" # orders
status: Literal["active", "archived", "all"] = "active" # recipes
Why not active: bool | None? The unset case is ambiguous (omit → "all" or "default"?) and there's no clean way to express "all" without a literal null string. The tri-state makes all three cases explicit and gives a clean Zod enum on the FE.
Default is "working set" (active / open / etc.) — aligns with the domain's ubiquitous language ("show me the recipes" means "the recipes I work with", not "the full historical archive"). One-off admin pages may override the default if the dominant task differs (e.g., an audit log defaults to all).
BE/FE coordination: URL stays clean at default state — the FE only writes the param when the user picks a non-default value. Route.useSearch() is the FE source of truth for the form widgets, and its validateSearch zod schema must mirror the BE default so widgets render the right value when the URL is empty. URL and form display stay in sync via the schema, not by writing redundant defaults to the URL.
Sort — ?sort=field / ?sort=-field, single-sort, stable tiebreaker¶
JSON:API-style. The sort allowlist lives in two coordinated places:
- A domain-layer
StrEnum({Noun}ListSortField) — the persistence-side allowlist used by the repo to look up the SQLAlchemy column. - An API-layer
Literal[...]of"field"+"-field"tokens ({Noun}SortToken) — the wire-side allowlist that Pydantic uses to validate thesort=query param and to emit a typed enum into the OpenAPI schema (so the FE's Orval/Zod codegen gets autocomplete + validation).
Both lists share the same member names. sort is a field on the single query model (never a standalone scalar Query() param — that re-triggers the flattening bug, see § One Query() parameter per list endpoint). The route reads params.sort, splits the optional - prefix, and resolves the bare value against the domain enum:
from typing import Annotated, Literal
from enum import StrEnum
from fastapi import Query
from src.shared.api.pagination import PaginationParams
from src.shared.api.sorting import SortDirection, parse_sort
# domain-layer allowlist — lives next to the repo ABC
class ItemListSortField(StrEnum):
NAME = "name"
CODE = "code"
CREATED_AT = "created_at"
UPDATED_AT = "updated_at"
ITEM_TYPE = "item_type"
STATUS = "status"
# API-layer typed token — lives in api/schemas/{noun}_queries.py
ItemSortToken = Literal[
"name", "-name",
"code", "-code",
"created_at", "-created_at",
"updated_at", "-updated_at",
"item_type", "-item_type",
"status", "-status",
]
DEFAULT_SORT = (ItemListSortField.CREATED_AT, SortDirection.DESC)
# sort is a field on the one query model, alongside pagination + filters
class ItemListQueryParams(PaginationParams):
# ... filters ...
sort: ItemSortToken | None = Field(
default=None, description="Sort field with optional '-' prefix."
)
@router.get("/items")
async def list_items(
params: Annotated[ItemListQueryParams, Query()],
):
field, direction = parse_sort(params.sort, ItemListSortField, default=DEFAULT_SORT)
...
Pydantic enforces the allowlist at validation time → 422 before the route body runs. parse_sort still runs (it splits the - prefix and maps to the domain enum); its own InvalidSortFieldError branch is unreachable for FastAPI callers because Pydantic already validated, but the function stays defensive for internal callers and future shapes. Single-sort only; multi-sort (shift-click) is deferred until a real use case appears.
One enum per resource, no extra API/domain split. Don't define a second StrEnum at the API layer that mirrors the domain enum — the Literal[...] is the only API-side shape and the route imports the domain enum directly. A mapping table between two identical enums is dead code.
Stable tiebreaker is mandatory. The repo helper apply_sort always appends id <same-direction> to the ORDER BY:
from src.shared.api.search import apply_sort
stmt = apply_sort(
select(ItemModel),
sort_column=ItemModel.name,
direction=direction,
id_column=ItemModel.id,
)
Without it, the row order on ties is implementation-defined and shifts between requests — breaking offset pagination even before snapshot considerations enter. The tiebreaker is invisible at the API contract level; never bypass it.
TanStack Table column-header click is the only sort UI on the FE. No separate "Sort by" dropdown.
Search — single q param, case + diacritic-insensitive¶
One free-text param per resource, matching across a predefined field allowlist declared at the handler/repo level (not configurable from the wire). Use search_any from src.shared.api.search to combine unaccent_ilike across N columns into one OR clause — every list endpoint with a q parameter must use this helper, not an inline or_(...):
from src.shared.api.search import search_any
# Module-level constant — the per-resource search-column allowlist:
_Q_SEARCH_COLUMNS = (
ItemModel.name,
ItemModel.code,
ItemModel.description,
ItemGroupModel.name,
func.array_to_string(ItemModel.tags, " "), # array columns are flattened
)
# Inside the repo's filter-builder:
if filters.q:
where_clauses.append(search_any(_Q_SEARCH_COLUMNS, filters.q))
Each scalar column in the allowlist gets a matching expression index in __table_args__ so the planner can use it:
The unaccent extension (and the IMMUTABLE promotion of its functions, required for expression indexes) is provisioned by the DB bootstrap init script deploy/postgres/init/01-create-roles.sh, not by a migration — CREATE EXTENSION / ALTER FUNCTION on a trusted extension's superuser-owned functions are outside the least-privilege migrations role's envelope. It's a one-time, repo-wide enablement, not a per-resource concern; migrations only create the lower(unaccent(col)) indexes.
search_any raises ValueError on an empty column list — callers must short-circuit on "no q" themselves before calling. Synthetic expressions (func.array_to_string(...), func.concat(...)) work as columns; they fall back to a seq scan rather than using an index, which is acceptable inside an already tenant-scoped query.
No FTS / tsvector yet — add when EXPLAIN shows ILIKE is the bottleneck.
Date-range filters — pair of scalar params¶
Inclusive bounds. Either side optional (partial ranges work). Same pattern for any time-bounded filter (updated_*, deactivated_*, etc.). Two scalar datetime | None params keep the OpenAPI / Zod types clean — don't pack ranges into a single delimited string.
Page + count repo helper — fetch_page¶
Every list-repo runs the same two-step shape: a SELECT for the current page (with WHERE + ORDER BY + OFFSET/LIMIT) plus a parallel SELECT COUNT(*) against the same WHERE. Hand-rolling both queries per repo invites drift between them (the count predicate falls behind a new filter, the page query gets an extra clause the count misses, etc.). src.shared.api.listing.fetch_page centralises the pattern so the count-WHERE-must-match-page-WHERE invariant is enforced by construction:
from src.shared.api.listing import fetch_page
from src.shared.api.search import search_any
# Per-resource bits (filters + join shape) stay inside the repo:
_Q_SEARCH_COLUMNS = (ItemModel.name, ItemModel.code, ItemModel.description, ...)
def _build_list_filters(*, tenant_id, filters, as_of) -> list[ColumnElement[bool]]:
where_clauses = [ItemModel.tenant_id == tenant_id, ItemModel.created_at <= as_of]
if filters.q:
where_clauses.append(search_any(_Q_SEARCH_COLUMNS, filters.q))
# ... other per-resource filter blocks ...
return where_clauses
async def list_items(self, *, ...) -> tuple[list[tuple[Item, str | None]], int]:
where_clauses = _build_list_filters(...)
join = ItemModel.__table__.join(ItemGroupModel, ..., isouter=True)
rows, total = await fetch_page(
self._session,
stmt=select(ItemModel, ItemGroupModel.name).select_from(join),
filters=where_clauses,
sort_column=_SORT_COLUMNS[sort_field],
id_column=ItemModel.id,
descending=descending,
offset=offset, limit=limit,
count_from=join, # COUNT runs against the join, not just the base table
)
return [(_to_domain(model), gn) for model, gn in rows], total
fetch_page appends the id tiebreaker to ORDER BY automatically (see § Sort), so the repo never has to remember it.
count_from is the FROM/JOIN expression the COUNT runs against:
- Plain single-table repo →
count_from=Model(orModel.__table__). - Repo that joins another table for filtering or projection →
count_from=<the .join(...) expression>so the COUNT sees the same row set as the page. Don't pass the bare base table when a join is in play — the count will be wrong for any filter that references the joined columns.
fetch_page does NOT own per-resource concerns: which columns to filter on, which columns to project, which sort column corresponds to which enum member. Those stay in the repo. Only the orchestration shape — apply filters, sort+tiebreaker, page slice, parallel count — is shared.
Stats endpoints — mandatory companion to every list resource¶
Every paginated GET /api/v1/<resource> list endpoint MUST ship with a companion GET /api/v1/<resource>/stats endpoint returning tenant-wide, filter-agnostic aggregate counts. No exceptions — read-mostly, admin-only, and system-curated resources all qualify. The FE renders a StatsBar (total / active / archived / by-type counts) above the table that needs unfiltered numbers; without a dedicated endpoint the FE would have to fire N parallel list calls at page_size=1 per page render, which is wasteful and breaks under any concurrent-request cap.
For picker / combobox reads (a third, distinct shape — { id, label }[], no envelope, bounded cardinality), see § List Resource Options Endpoints. Don't fold picker shapes into /stats; the shapes, lifetimes, and invalidation surfaces are different.
The shape rules below are the contract — every stats endpoint matches it.
Shape contract
- One flat Pydantic response class per stats endpoint,
model_config = ConfigDict(extra="forbid"). - Fields are scalars or one-level-nested breakdown sub-models (see below). No arrays of objects, no deeper nesting.
- A required
total: intfield on every stats response — every endpoint has it. - Per-dimension breakdowns are named
by_<dimension>(by_status,by_item_type, …) and typed as a dedicated sub-model whose fields are the enum values — NOTdict[str, int]and NOTdict[EnumType, int]. The response is exhaustive — every enum member is a concrete field, zero-count members included as0, nevernulland never omitted. The wire JSON is identical to a plain map ({ "active": 118, "inactive": 6 }), but OpenAPI now emits concretepropertiesinstead of a key-lessadditionalProperties, so FE codegen produces precise key types and faker-seeded mocks fill the real keys. This lets the FE render a static bar without conditional layout. A baredict[str, int]erases the keys from the contract — see Forbidden anti-patterns. - Generate the breakdown sub-model from the domain enum so the enum stays the single source of truth and the schema cannot drift — a new enum member adds its field automatically. Use the shared
build_stats_breakdown(name, enum)helper fromsrc.shared.api.stats(it wrapspydantic.create_model, pinsextra="forbid", and absorbs thecreate_modeltyping friction in one place). (dict[EnumType, int]does NOT work: Pydantic v2 still serializes enum-keyed dicts asadditionalProperties, so the keys never reach the contract.) - No query parameters at all. No
q, noitem_type, nostatus— the endpoint always returns tenant-wide counts. Filter-aware aggregates belong in the paginated list response's metadata, not here (see § Filter parameters). - No pagination, no sort, no cursor.
Example shape:
# src/modules/item_catalog/api/schemas/item_queries.py
from pydantic import BaseModel, ConfigDict
from src.modules.item_catalog.domain.value_objects import ItemStatus, ItemType
from src.shared.api.stats import build_stats_breakdown
# Breakdown sub-models generated from the domain enums → OpenAPI gets concrete
# properties (active/inactive, every ItemType), enum is the single source of truth.
ItemStatsByStatus = build_stats_breakdown("ItemStatsByStatus", ItemStatus)
ItemStatsByItemType = build_stats_breakdown("ItemStatsByItemType", ItemType)
class ItemStatsResponse(BaseModel):
model_config = ConfigDict(extra="forbid")
total: int
# A generated model used as a field type trips mypy's `valid-type` check
# (it's a value, not a statically-known class) — silence it on the field line.
by_status: ItemStatsByStatus # type: ignore[valid-type]
by_item_type: ItemStatsByItemType # type: ignore[valid-type]
The route constructs each sub-model from the handler's exhaustive enum-valued dict —
by_status=ItemStatsByStatus(**result.by_status). The application/query layer keeps returning
plain dict[str, int]; the enum→property mapping is purely an API/OpenAPI concern.
// GET /api/v1/items/stats →
{
"total": 124,
"by_status": { "active": 118, "inactive": 6 },
"by_item_type": { "ingredient": 80, "product": 22, "packaging": 14, "intermediate": 8, "consumable": 0, "service": 0, "resale": 0 }
}
Layering — standard query path: API → Handler → Repo → SQL. Mirrors the list-endpoint layering above; only the inputs and shape differ.
- Repo method named
stats(tenant_id)returns a typeddataclass(e.g.ItemStats), never a raw dict. It issues exactly one SQL statement usingGROUP BYandFILTER (WHERE …)clauses — never N+1 across dimension keys. Tenant isolation rides on RLS (see § Authorization), not a hand-rolledWHERE tenant_id. - Handler lives in
application/queries/get_<resource>_stats.pyand exposesGet<Resource>StatsQuery+Get<Resource>StatsResult+Get<Resource>StatsHandler, following § CQS Pattern → Query Handler. Decorate the handler with@traced_handler. - API route lives in the same
api/routers/<resource>.pyfile as the list route. DeclareGET /<resource>/statsBEFOREGET /<resource>/{id}— FastAPI matches routes in declaration order andstatswould otherwise be swallowed as the{id}path param.
Authorization — gate the stats endpoint with the same Permission as the corresponding list endpoint (e.g. Permission.ITEM_READ for /items/stats). Do not introduce a separate <RESOURCE>_STATS_READ permission — anyone who can see the list can see its aggregate. See § Authorization for the broader permission-gating rules.
Caching — out of scope for v1. No cache layer, no Cache-Control header. A tenant-wide change feed + ETag is the eventual hook; revisit once a hot stats endpoint surfaces in profiling.
Required tests (per implementation, not for the convention itself):
- Repo test asserting exactly one SQL statement is issued for
stats(tenant_id)— use theassert_num_queries-style helper orsqlalchemy.event.listen('after_cursor_execute')to count statements. - Integration test seeding ≥1 row per enum value, asserting every dimension key appears with the correct count (including zero-count keys after deleting/skipping a value).
- RLS test: seeded rows in tenant A are invisible from tenant B (response is zeroed out).
- Schema-contract test asserting each breakdown sub-model's field set equals the enum's value set (
set(ItemStatsByStatus.model_fields) == {m.value for m in ItemStatus}). Withcreate_modelthis can't drift, but the test pins the contract so a future hand-rolled sub-model or a renamed enum member fails loudly. Lives intests/unit/modules/<module>/api/test_stats_schemas.py.
Forbidden anti-patterns
- ❌ Typing a breakdown as
dict[str, int](ordict[EnumType, int]). Both render in OpenAPI as a key-lessadditionalPropertiesmap, erasing the enum keys from the contract — FE codegen degrades to{ [key: string]: number }and faker-seeded mocks emit random keys, so the StatsBar shows zeros (and historically crashed). Always acreate_model-from-enum sub-model with concreteproperties. - ❌ Accepting any query parameter on the stats endpoint.
- ❌ Returning
nullfor a dimension key — always0, always exhaustive. - ❌ Computing stats inside the list endpoint and folding them into the list response envelope. List
totalstays filter-aware (paginates the current view); stats counts stay filter-agnostic (a stable "what's in my catalog" indicator). Two separate concerns, two separate endpoints. - ❌ N+1 — one SQL statement per dimension key. Always a single
GROUP BY/FILTER (WHERE …)query.
Pattern, not framework — each entity declares its own /stats route with its own response shape that matches the banner. Don't try to build a generic stats engine ahead of demand (see "What we deferred" below).
List Resource Options Endpoints — picker shapes for combobox / select UIs¶
A third API pattern alongside § List Endpoints and § Stats endpoints. Picker / select / combobox UIs ask "what are the choices?" — and the answer is neither a paginated list nor a stats aggregate. Populating a picker from GET /api/v1/<resource> ships 10× more bytes than needed, forces an awkward page_size=1000-or-paginate decision, and couples the picker cache to the list-page cache (same query key → invalidation collisions even when the picker doesn't care about non-name changes).
When the endpoint is required
A resource MUST expose GET /api/v1/<resource>/options[?<scope>] when both of these are true:
- It is the target of a "pick one from a list" picker (combobox / select) on the FE.
- Per-tenant cardinality is bounded — practical cap < ~500 rows.
A resource MUST NOT expose /options (use the list endpoint with ?q= server-side typeahead instead) when:
- Cardinality is unbounded (items, customers, sales orders) —
/optionswould balloon the payload.
A resource does not need /options at all when it never appears in a picker.
Shape contract
- One flat Pydantic class per options endpoint,
model_config = ConfigDict(extra="forbid"), with exactly two fields: id: UUIDlabel: str(the human-facing name)- Bare JSON array response —
list[<Resource>OptionResponse]. Not wrapped in an{ items, total }envelope. The picker consumes the whole filtered list in one fetch; pagination is not a concept here. - Optional scope params — may accept flat enum / UUID scope filters that mirror the resource's intrinsic partitioning (e.g.
item_typefor item-groups,warehouse_idfor locations). Scope params follow the same flat-Pydantic-model rule as § Filter parameters. - No
qsearch, nosort, nopage/page_size, nocursor. Picker filters the array locally via cmdk. Server-side typeahead resources don't qualify — they use the list endpoint. - Sorted alphabetically by label server-side (stable, predictable for both UI scan and snapshot tests).
- Excludes archived / inactive rows by default — a picker shows pickable choices. Soft-deleted / deactivated entities never appear.
Example shape:
# src/modules/item_catalog/api/schemas/item_group_queries.py
from pydantic import BaseModel, ConfigDict
class ItemGroupOptionResponse(BaseModel):
model_config = ConfigDict(extra="forbid")
id: UUID
label: str # the group's name
class ListItemGroupOptionsQueryParams(BaseModel):
model_config = ConfigDict(extra="forbid")
item_type: ItemType | None = None # optional scope filter
// GET /api/v1/item-groups/options?item_type=ingredient →
[
{ "id": "8d2d…-…-…", "label": "Drożdże" },
{ "id": "4a91…-…-…", "label": "Mąki" },
{ "id": "0c11…-…-…", "label": "Sole" },
{ "id": "1f70…-…-…", "label": "Słodziki" }
]
Layering — standard query path: API → Handler → Repo → SQL. Mirrors § Stats endpoints; only the inputs and shape differ.
- Repo method named
list_options(tenant_id, *scope_filters)returnslist[<Resource>Option]where<Resource>Optionis a frozen dataclass withid+label. One SQL statement:SELECT id, name AS label FROM <table> WHERE … [scope] AND deleted_at IS NULL ORDER BY label. Tenant isolation rides on RLS (see § Authorization), not a hand-rolledWHERE tenant_id. - Handler lives in
application/queries/list_<resource>_options.pyand exposesList<Resource>OptionsQuery+List<Resource>OptionsResult+List<Resource>OptionsHandler, following § CQS Pattern → Query Handler. Decorate the handler with@traced_handler. - API route lives in the same
api/routers/<resource>.pyas the list route. DeclareGET /<resource>/optionsBEFOREGET /<resource>/{id}— FastAPI matches routes in declaration order andoptionswould otherwise be swallowed as the{id}path param. Same precedence pitfall as/stats.
Authorization — gate the options endpoint with the same Permission as the corresponding list endpoint (e.g. Permission.ITEM_VIEW for /item-groups/options). Do not introduce a separate <RESOURCE>_OPTIONS_READ permission — anyone who can browse the list can populate a picker. See § Authorization for the broader permission-gating rules.
Caching — out of scope for v1. No cache layer, no Cache-Control header. ETag is the eventual hook once a tenant-wide change feed is wired; revisit once a hot options endpoint surfaces in profiling.
Required tests (per implementation, not for the convention itself):
- Repo test asserting exactly one SQL statement is issued for
list_options(tenant_id, …)— use the sameassert_num_queries-style helper the/statsrepo test uses. - Integration test seeding rows across each scope value, asserting:
- only matching rows returned (scope filter works),
- alphabetical ordering by label,
- bare-array shape (no envelope),
- archived / inactive rows excluded (where the resource has soft-delete).
- RLS test: tenant A's options invisible from tenant B.
Forbidden anti-patterns
- ❌ Returning full domain objects (
Group,Item,Customer, …) — keep the shape to{ id, label }. - ❌ Pagination,
total, or any envelope wrapper. - ❌ Server-side search (
?q=) — push consumers onto the list endpoint for that. - ❌ Routing
/optionsthrough the same query handler as the list endpoint — different shapes, different cache lifetimes, different invalidation surface. - ❌ Per-resource permission like
ITEM_GROUP_OPTIONS_READ— reuse the list permission.
Query handler shape¶
List endpoints follow the existing query-handler pattern (see § CQS Pattern → Query Handler):
application/queries/list_{noun}.pycontainsList{Noun}Query(dataclass),{Noun}ListReadModel(dataclass — flat row shape, never the domain aggregate),List{Noun}Handlerdecorated with@traced_handler.- The API-layer Pydantic params (the single
{Noun}ListQueryParamsmodel — pagination + filters + sort, inheritingPaginationParams) map into the domain-layerList{Noun}Queryin the route viaparams.to_filters()+ the parsed sort tuple. Pydantic schemas don't leak intoapplication/. - The handler returns
(list[{Noun}ListReadModel], total). The route builds thePaginatedResponse[T]envelope. - The repo's
list()method materialises read models directly — never hydrates the full aggregate. Reads bypass domain validation; writes go through the aggregate.
Permissions / RLS¶
- Every list endpoint gated with
Depends(require_permission(Permission.X_VIEW))— declare the permission + itsPERMISSION_ROLESentry in the same commit as the endpoint. - Tenant isolation via RLS — no manual
WHERE tenant_idin the repo. The session-scopedcurrent_setting('app.current_tenant')does the filtering.
What we deferred (and why)¶
| Deferred | Trigger to revisit |
|---|---|
| Multi-column sort (shift-click) | A real UX requirement for it surfaces |
Postgres FTS / tsvector |
EXPLAIN shows ILIKE is the bottleneck on a hot endpoint |
| Cursor pagination | A single tenant exceeds ~1M rows in a paginated table |
| Generic stats framework | More than three resources reimplement the same shape |
Filter operators beyond equality / IN / date-range (>, <, contains-any) |
A specific resource's business rules require them |
| Server-side saved-filter presets | After URL-sharing proves insufficient |
PATCH Endpoint Conventions¶
These rules apply to every PATCH /api/v1/{resource}/{id} endpoint that exposes partial updates on a flat aggregate. They were established in GRI-13 (PATCH /items/{item_id}) and are mandatory for every future Edit* command.
When to use PATCH vs PUT vs POST-with-verb vs sub-resources¶
| Resource shape | Pattern | Why |
|---|---|---|
| Flat aggregate, scalar fields only | PATCH /resource/{id} with the UNSET sentinel |
Mixed-mutability fields, system-managed fields, and field-level events all need partial-update semantics |
| Domain state transition with business meaning | POST /resource/{id}/{verb} |
The verb encodes domain semantics (deactivate, submit, issue, cancel); event payloads are state-machine-shaped |
| Set or clear a single FK relationship | PUT /resource/{id}/relation to set, DELETE /resource/{id}/relation to clear |
The relationship itself is the resource; PUT semantics fit |
| Aggregate with editable child collections (BOM lines, order items) | Sub-resource endpoints: POST /resource/{id}/children, PUT /resource/{id}/children/{child_id}, DELETE /resource/{id}/children/{child_id} |
Don't try to PATCH a nested array — server can't compute add/remove/reorder cheaply, and event payloads explode |
The parent aggregate's PATCH endpoint always edits scalar fields only. Child collections are managed via their own URLs.
The tri-state field problem¶
A PATCH endpoint must distinguish three client intents per editable field:
| Intent | JSON shape | Server behaviour |
|---|---|---|
| Leave field unchanged | field is absent from the body | no comparison, no event |
| Set field to a new value | "field": <value> |
compare with current; emit if different |
| Clear an optional field | "field": null |
set to NULL; emit if previously non-null |
A naive field: T \| None = None collapses cases 1 and 3 — the server can't tell "leave EAN alone" from "delete the EAN". This breaks no-op detection, breaks audit trails, and breaks downstream consumers that key off old_value → new_value.
The shared UNSET sentinel (Pydantic-native)¶
src/shared/sentinels.py exports a singleton UNSET whose only purpose is to mean "this field was not provided." It is a third value distinct from None and from every user value, with its own type (_UnsetType) so mypy can narrow correctly.
The sentinel is Pydantic-aware: it implements __get_pydantic_core_schema__ and __get_pydantic_json_schema__ so Pydantic schemas can use T | _UnsetType = Field(default=UNSET, ...) directly. The route receives body.fieldname already set to either UNSET (field absent), None (explicit null), or the parsed value — no model_fields_set juggling needed.
# src/shared/sentinels.py — public API
UNSET: Final[_UnsetType] # the singleton
# Pydantic integration on _UnsetType:
# __get_pydantic_core_schema__ — UNSET cannot appear in user input;
# only valid as a default when the field is absent
# __get_pydantic_json_schema__ — returns {} so the sentinel does NOT pollute
# the OpenAPI emission with a meaningless type
The OpenAPI emission point matters: client-side codegen and FE form generators consume the OpenAPI schema. The sentinel must be invisible there — clients should see the field as "optional with a normal type", not "optional with a mystery _UnsetType arm in a union".
PATCH schema pattern¶
The Pydantic schema is the single source of truth for what the endpoint accepts. It only declares mutable fields; immutable fields are rejected by a class-level validator with the right 400 code, so they never appear in the OpenAPI emission and never show up in FE-generated forms.
Every PATCH schema inherits from PatchRequestModel (in src/shared/api/schemas.py), which centralises the cross-cutting rules so every endpoint behaves identically and no schema can accidentally skip them.
The shared base — PatchRequestModel¶
# src/shared/api/schemas.py
from typing import Any, ClassVar
from pydantic import BaseModel, ConfigDict, model_validator
from src.shared.exceptions import ImmutableFieldCannotBeChangedError
class PatchRequestModel(BaseModel):
"""
Base class for every ``PATCH`` endpoint's request schema.
Centralises three rules that apply to every PATCH endpoint
(see ``docs/architecture/conventions.md`` § PATCH Endpoint Conventions):
1. ``extra="forbid"`` — unknown fields produce 422 ``VALIDATION_ERROR``.
2. Mutable fields are declared explicitly by the subclass with
``T | _UnsetType = Field(default=UNSET, ...)`` (or
``T | None | _UnsetType`` for clearable fields).
3. Immutable fields are listed in ``_IMMUTABLE_FIELDS`` (a class-level
``frozenset[str]``). The ``model_validator`` below rejects any
attempt to send them with **400** ``IMMUTABLE_FIELD_CANNOT_BE_CHANGED``.
Subclasses with no immutable fields simply omit the declaration — the
empty default ``frozenset()`` means "nothing is off-limits."
"""
model_config = ConfigDict(extra="forbid")
_IMMUTABLE_FIELDS: ClassVar[frozenset[str]] = frozenset()
@model_validator(mode="before")
@classmethod
def _reject_immutable_fields(cls, data: Any) -> Any:
"""Raise the domain 400 if the client attempted to mutate any immutable field."""
if isinstance(data, dict):
attempted = cls._IMMUTABLE_FIELDS & set(data.keys())
if attempted:
raise ImmutableFieldCannotBeChangedError(
f"Immutable fields cannot be changed: {sorted(attempted)}.",
)
return data
The base lives in src/shared/api/schemas.py (next to the other shared API utilities like deps.py and router.py). The ImmutableFieldCannotBeChangedError lives in src/shared/exceptions.py since the concept is use-case-agnostic — every module's Edit* command can raise it.
A concrete subclass¶
# src/modules/item_catalog/api/schemas/items.py
from typing import ClassVar
from pydantic import Field, field_validator
from src.modules.item_catalog.domain.value_objects.tags import MAX_TAG_COUNT, MAX_TAG_LENGTH
from src.shared.api.schemas import PatchRequestModel
from src.shared.sentinels import UNSET, _UnsetType
class EditItemRequest(PatchRequestModel):
"""Request body for ``PATCH /api/v1/items/{item_id}``."""
_IMMUTABLE_FIELDS: ClassVar[frozenset[str]] = frozenset({"code", "item_type", "storage_unit"})
# Mutable, non-nullable fields — sentinel-or-value:
name: str | _UnsetType = Field(default=UNSET, min_length=1, max_length=200)
tags: list[str] | _UnsetType = Field(default=UNSET, max_length=MAX_TAG_COUNT)
# Mutable, nullable (clearable) fields — sentinel-or-value-or-null:
description: str | None | _UnsetType = Field(default=UNSET, max_length=500)
shelf_life_days: int | None | _UnsetType = Field(default=UNSET, gt=0)
ean: str | None | _UnsetType = Field(default=UNSET, min_length=1, max_length=20)
@field_validator("tags")
@classmethod
def _enforce_per_tag_length(cls, value: list[str] | _UnsetType) -> list[str] | _UnsetType:
"""Reject any tag longer than ``MAX_TAG_LENGTH`` characters (skip if UNSET)."""
if isinstance(value, _UnsetType):
return value
for tag in value:
if len(tag) > MAX_TAG_LENGTH:
raise ValueError(f"Each tag must be at most {MAX_TAG_LENGTH} characters.")
return value
The subclass shape is now: _IMMUTABLE_FIELDS declaration + field declarations + any per-field validators. No model_config, no model_validator — both inherited.
Why the immutable check is in the schema, not the handler:
- The schema is what produces the OpenAPI spec. Front-end form generators consume it. If the schema declares
code, FE forms will show acodefield that the BE will then reject — confusing UX. - The rejection still happens before any I/O (it's
model_validator(mode="before")on the base). - The shared exception handler maps
ImmutableFieldCannotBeChangedErrorto the contract-mandated 400 withIMMUTABLE_FIELD_CANNOT_BE_CHANGED. Pydantic's own "extra inputs are not permitted" would produce 422 with the genericVALIDATION_ERROR— wrong status, wrong code, wrong UX. extra="forbid"is still on for genuinely unknown fields (typos, junk) — those continue to produce 422 as expected.
Nullable vs non-nullable decision per field:
- Non-nullable (
T | _UnsetType): the field can never be cleared on this aggregate. Sendingnullis a client bug. Pydantic will raise a type error because the schema's union doesn't includeNone. The client sees 422 with a useful Pydantic message. - Nullable (
T | None | _UnsetType): the field can be cleared. Sendingnullis the valid way to set it back to NULL in the DB.
Checklist when writing a new Edit* schema:
- Inherit from
PatchRequestModel(NOTBaseModel). - Declare
_IMMUTABLE_FIELDS: ClassVar[frozenset[str]] = frozenset({...})listing every field that cannot change after creation. Omit the declaration if nothing is immutable. - Declare each mutable field with
T | _UnsetType(non-nullable) orT | None | _UnsetType(clearable), defaulting toUNSETviaField(default=UNSET, ...). - Add per-field validators only for constraints that go beyond the standard
min_length/max_length/gt/ etc — e.g. per-element checks on a list. - Do not redeclare
model_config = ConfigDict(extra="forbid")— it's inherited. - Do not add your own
_reject_immutable_fieldsvalidator — it's inherited.
Command + handler + domain layers¶
Because the schema does the field-absence translation, every layer below it sees the same T | _UnsetType (or T | None | _UnsetType) and never needs to know about the HTTP-side specifics.
# application/commands/edit_item.py
@dataclass
class EditItemCommand:
"""Inputs for UC-002 EditItem. No immutable-field plumbing — the schema handles that."""
item_id: UUID
tenant_id: UUID
updated_by: UUID
name: str | _UnsetType = UNSET
description: str | None | _UnsetType = UNSET
tags: list[str] | _UnsetType = UNSET
shelf_life_days: int | None | _UnsetType = UNSET
ean: str | None | _UnsetType = UNSET
@dataclass
class EditItemResult:
item_id: UUID
changed: bool
version: int
class EditItemCommandHandler:
async def handle(self, command: EditItemCommand) -> EditItemResult:
item = await self._item_repo.get_by_id(command.item_id, command.tenant_id)
if item is None:
raise ItemNotFoundError(...)
if item.is_system:
raise SystemItemIsReadOnlyError(...)
# EAN uniqueness pre-check (only when the client actually sent a non-null EAN):
if not isinstance(command.ean, _UnsetType) and command.ean is not None:
existing = await self._item_repo.get_by_ean(command.ean, command.tenant_id)
if existing is not None and existing.id != item.id:
raise EANAlreadyInUseError(...)
old_ean = item.ean.value if item.ean is not None else None
changed = item.edit(
updated_by=command.updated_by,
name=command.name,
description=command.description,
tags=command.tags,
shelf_life_days=command.shelf_life_days,
ean=command.ean,
)
if not changed:
return EditItemResult(item_id=item.id, changed=False, version=item.version)
await self._item_repo.save(item)
await self._event_publisher.publish(<entity>Updated(...))
return EditItemResult(item_id=item.id, changed=True, version=item.version)
Canonical comparison idiom in the domain method:
def edit(self, *, name: str | _UnsetType = UNSET, ..., updated_by: UUID) -> bool:
changed = False
if not isinstance(name, _UnsetType) and name != self.name:
self.name = name
changed = True
# … one block per editable field …
if changed:
self.updated_at = datetime.now(tz=timezone.utc)
self.updated_by = updated_by
return changed
The route is a pure pass-through¶
Because the schema does the tri-state translation and rejects immutable fields, the route has nothing to do but map fields onto the command:
# api/routers/items.py
@items_router.patch("/{item_id}", status_code=200, response_model=EditItemResponse)
@idempotent
async def edit_item(
item_id: UUID,
body: EditItemRequest,
handler: EditItemHandlerDep,
user: Annotated[CurrentUser, Depends(require_permission(Permission.ITEM_UPDATE))],
) -> EditItemResponse:
"""UC-002: partial-update an Item using the UNSET sentinel pattern."""
result = await handler.handle(
EditItemCommand(
item_id=item_id,
tenant_id=user.tenant_id,
updated_by=user.user_id,
name=body.name,
description=body.description,
tags=body.tags,
shelf_life_days=body.shelf_life_days,
ean=body.ean,
)
)
return EditItemResponse(item_id=result.item_id, changed=result.changed, version=result.version)
No model_fields_set, no assertions, no immutable-set extraction. The route honours CLAUDE.md's "routes are thin" rule literally — it only maps fields.
OpenAPI / FE form generation¶
The schema's emitted OpenAPI schema is what front-end form generators consume. Because the schema only declares mutable fields, the generated FE form only contains mutable fields — no code/item_type/storage_unit boxes for the user to fight with.
If you ever need a field to appear in OpenAPI but be rejected, do not add it to the schema. Instead either (a) extend _IMMUTABLE_FIELDS and document the reason, or (b) extend _IMMUTABLE_FIELDS and add a description= to the OpenAPI docs explaining why the field is read-only on this verb. The current default — don't show what can't be sent — is preferred unless there's a concrete reason.
No-op detection contract¶
A pure no-op PATCH (no field changed) must:
- Return 200 with
{"changed": false}. - Skip
repo.save()— no UPDATE, no version bump, noupdated_at/updated_bychange. - Skip
event_publisher.publish()— no outbox row, no event dispatched.
The domain method is the single source of truth for "did anything change." Its signature returns bool:
A no-op edit and an absent field are semantically equivalent: submitting {"name": "<current value>"} produces the same outcome as submitting {}. Both return changed: false.
version in the PATCH response¶
Every PATCH response body includes the post-update version:
Returning the new version lets the FE chain edits without an extra GET. On a no-op, return the current version unchanged. This pairs with OptimisticLockMixin — the FE keeps the latest version and the BE rejects stale versions with 409 CONCURRENT_MODIFICATION (already wired in SqlAlchemyItemRepository.save()).
A future iteration may add ETag / If-Match headers as a stricter alternative to embedding version in the body — don't add it speculatively.
Required tests for every PATCH endpoint¶
Beyond the standard create-endpoint coverage, every PATCH endpoint must include:
- No-op test (integration) — submit
{}(or values matching current) and assert200 { "changed": false }plus zero new outbox rows for the aggregate. - Sentinel-clears test (integration) — submit explicit
nullfor a nullable field and assert the field is cleared in the DB,changed: true, exactly one outbox row. - Immutable-field test (integration) — submit each immutable field and assert 400
IMMUTABLE_FIELD_CANNOT_BE_CHANGED. Parametrize over_IMMUTABLE_FIELDSto cover the whole set in one test. This lives at the integration layer because the rejection happens in the Pydantic schema, not in the handler — there's nothing to unit-test below it. - Unknown-field test (integration) — submit a junk field and assert 422
VALIDATION_ERROR(provesextra="forbid"is still active). - Optimistic concurrency test (integration) — stale
versionproduces 409CONCURRENT_MODIFICATION.
The handler-level unit tests still cover: happy path with field changes, no-op detection, system-item rejection, EAN-uniqueness conflict, not-found, domain invariants like shelf-life applicability. They do not test the immutable-field rejection (no longer reachable at this layer).
Database Conventions¶
Table Naming¶
- snake_case for tables and columns
- singular for table names (
recipe, notrecipes) - id for primary key
Column Naming¶
- Foreign keys:
{table}_id(e.g.,recipe_id) - Timestamps:
{action}_at(e.g.,created_at,published_at) - Boolean:
is_{state}(e.g.,is_active)
Schema¶
CREATE TABLE recipe (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
code VARCHAR(50) NOT NULL,
name VARCHAR(255) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'draft',
current_version INT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ,
created_by UUID NOT NULL,
updated_by UUID,
CONSTRAINT uq_recipe_code_tenant UNIQUE (tenant_id, code)
);
-- RLS — baseline for new tables
ALTER TABLE recipe ENABLE ROW LEVEL SECURITY;
ALTER TABLE recipe FORCE ROW LEVEL SECURITY; -- applies to the table owner too
CREATE POLICY tenant_isolation ON recipe
USING (tenant_id = current_setting('app.current_tenant')::uuid)
WITH CHECK (tenant_id = current_setting('app.current_tenant')::uuid);
RLS pattern notes. Plain
ENABLEis bypassed by the table owner — and the dev DB role (grinsystem) is currently a Postgres superuser withBYPASSRLS, so policies defined withENABLEalone never actually filter in dev or tests.FORCEcloses the owner bypass; running as a non-superuser, non-BYPASSRLSrole (separately tracked) closes the superuser bypass.WITH CHECKmirrorsUSINGfor INSERT/UPDATE so writes can't slip a row with a foreigntenant_idpast the policy.Every table ships this tightened baseline. The migration chain was squashed pre-release into three revisions —
0001_baseline(empty anchor),0002_platform_foundation(sharedoutbox),0003_item_catalog_foundation(item+item_group) — all usingENABLE+FORCE+USING+WITH CHECK.
SQLAlchemy Mixins¶
The shared columns above are composed into every aggregate-root model via mixins in src/shared/db/mixins.py. Do not redeclare these columns per model — that's how naming, nullability, and timezone settings drift.
| Mixin | Provides |
|---|---|
UUIDPkMixin |
id: UUID primary key |
TenantScopedMixin |
tenant_id: UUID NOT NULL + index |
TimestampMixin |
created_at: TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at: TIMESTAMPTZ with onupdate=NOW() |
AuditedByMixin |
created_by: UUID NOT NULL, updated_by: UUID |
OptimisticLockMixin |
version: INT NOT NULL DEFAULT 0 plus __mapper_args__ = {"version_id_col": version} so SQLAlchemy auto-bumps on every UPDATE and raises StaleDataError on lost races. |
from src.shared.db.base import Base
from src.shared.db.mixins import (
AuditedByMixin, OptimisticLockMixin, TenantScopedMixin, TimestampMixin, UUIDPkMixin,
)
class RecipeModel(
UUIDPkMixin,
TenantScopedMixin,
TimestampMixin,
AuditedByMixin,
OptimisticLockMixin,
Base,
):
__tablename__ = "recipe"
code: Mapped[str] = mapped_column(String(50), nullable=False)
name: Mapped[str] = mapped_column(String(255), nullable=False)
# ...domain-specific columns only
Domain entities do NOT inherit these mixins — domain stays pure-Python. The fields are mirrored in the dataclass; the convention is documentation, not enforcement.
Optimistic Concurrency Control¶
OptimisticLockMixin is the default for every mutable aggregate, not the exception. Add it unless you can name a specific reason not to (purely immutable rows like Invoice once issued, or strictly single-writer rows like an in-flight import staging table).
Why default-on: two people editing the same record at the same time is a normal occurrence in a multi-user ERP. Without versioning the second save silently overwrites the first. With versioning the second save raises ConcurrentModificationError and the API returns 409, the client refreshes, the user re-applies their edit.
End-to-end flow when this is enabled — must be implemented consistently across entity / model / repo / handler / API:
- Model — composes
OptimisticLockMixin. A freshly persisted row isversion = 0; SQLAlchemy bumpsversionby 1 on every UPDATE and raisesStaleDataErrorif zero rows match(id=X AND version=old). The 0 start is guaranteed by the mixin's customversion_id_generator— SQLAlchemy's built-in generator would otherwise stamp 1 on the first INSERT, contradicting both the entity'sversion=0default and the column'sserver_default="0". - Entity — mirrors a
version: intfield. The repo populates it onget_by_id; the API returns it in every read; the client sends it back on every write. - Repository — translates
StaleDataErrorfromflush()intoConcurrentModificationError(HTTP 409). Writes the post-flushmodel.versionback onto the entity so the handler/API can return the new version to the client. - API — exposes
versionon every read response (e.g.ItemRead.version) and on every write response so the client always has the post-write version to send next time. Every editable-aggregate write request body declaresversion: intas a required field (no default); a missing or non-integerversionreturns 422 at the Pydantic boundary. Body-less endpoints (POST /{id}/deactivate,POST /{id}/reactivate,DELETE /{id}/group) accept a minimal{ "version": int }JSON body — the request body is the single, uniform OCC transport across the whole API (noIf-Matchheader). - Handler — after loading the aggregate (and after the not-found / actor-aware authorization guards), compares
command.versionagainst the loadedentity.version. A mismatch raisesConcurrentModificationErrorbefore any expensive cross-module port call, before any state-machine pre-check, and before any domain mutation. The version check is a request-scoped concurrency guard (not a state-machine invariant), so it lives in the handler — the entity has nocheck_version()method. Use the same wording as the repo'sStaleDataErrorbranch so the two error messages stay aligned:"{Aggregate} '{id}' was modified by another writer; refresh and retry." - Error —
ConcurrentModificationError(ConflictError)insrc/shared/exceptions.py. The shared exception handler renders it as409 { "error": { "code": "CONCURRENT_MODIFICATION", ... } }. Client retry strategy: refresh the resource, re-apply the edit, re-submit with the new version.
Precedence — when multiple guards could fire on the same request:
404 (not-found) → 403 (actor-aware, e.g. is_system) → 409 (stale version) → 400 (state-machine pre-check, where the precedence-exception in § Guard Placement applies) → cross-module guards → domain mutation.
A stale view makes every later check meaningless, so 409 stale-version wins over 400 state-machine and over any later 409 cross-module conflicts on the same request. 404 / 403 are more fundamental rejections (the resource doesn't exist or can't be edited at all) so they precede the version check. Each handler with multiple failure modes gets one precedence test asserting this ordering (configure two failures at once, assert stale-version wins).
Aggregates that do use it (Phase 1):
- ItemCatalog: Item, ItemGroup (multiple editors per tenant)
- Manufacturing: Recipe, ProductionOrder (collaborative editing + status transitions)
- Warehouse: LOT, StockEntry (reservations vs consumption races)
- CRM: Customer, SalesOrder (sales rep + customer service)
Aggregates that do not need it:
- Identity: AuditLogEntry (append-only)
- Manufacturing: RecipeNutritionSnapshot, ProductionCostRecord (immutable once written)
- Invoicing: Invoice (immutable once issued), CreditNote
- Traceability: BatchGenealogyLink, LotMovementRecord, Recall (append-only)
Indexes¶
Declare indexes on the SQLAlchemy model, not in the migration. That way make migrate-new autogenerate sees them and emits the right op.create_index(...) automatically — same source of truth, no drift.
from sqlalchemy import Index, func
class ItemModel(...):
__tablename__ = "item"
__table_args__ = (
# plain composite indexes
Index("ix_item_tenant_id_item_type", "tenant_id", "item_type"),
Index("ix_item_tenant_id_item_group_id", "tenant_id", "item_group_id"),
# expression index — autogenerate emits this correctly in Alembic >= 1.11
Index("uq_item_tenant_id_code", "tenant_id", func.lower(text("code")), unique=True),
)
Single-column indexes can also be inlined with mapped_column(..., index=True) — fine for tenant_id from TenantScopedMixin. Composite and expression indexes always go in __table_args__.
What you still hand-edit in the migration:
- ENABLE + FORCE ROW LEVEL SECURITY and CREATE POLICY tenant_isolation with USING and WITH CHECK (see § Schema)
- CHECK constraints with non-trivial expressions
- Data backfills
Everything else — including expression-based unique indexes — belongs on the model.
Migrations¶
Always autogenerate. Never hand-write a migration from scratch.
Hand-edit the generated file only for:
- ENABLE + FORCE ROW LEVEL SECURITY and CREATE POLICY tenant_isolation with USING and WITH CHECK (see § Schema)
- CHECK constraints with non-trivial expressions
- Data backfills
Why: autogenerate uses Base.metadata + NAMING_CONVENTION directly, so constraint and index names (pk_*, fk_*, ix_*, uq_*) stay in sync with the model. Hand-written migrations drift. Indexes (including expression indexes) live on the model — see § Indexes below.
Seeding¶
Sample/dev data is never put in migrations — schema (Alembic) and data (seeders) stay separate so migrations remain deterministic and alembic check-able. Seeders are env-gated, idempotent, per-module, and run as a one-shot (python -m src.shared.seeding). See docs/architecture/seeding.md for the full standard, including how to add a seeder for a new module.
UUID Generation¶
Every aggregate ID uses UUIDv7 — time-ordered. Generate via the shared helper:
Why not v4: v4 is random, so consecutive inserts hit random B-tree pages, fragmenting indexes and slowing inserts under load. v7's leading timestamp makes inserts monotonically increasing → tight indexes, better cache behaviour, ~10–20% throughput on write-heavy tables. The change is invisible to consumers (still 128-bit UUIDs over the wire). Never call uuid4() directly — always go through new_id().
Event Conventions¶
Topic Naming¶
Event Naming¶
- Past tense:
RecipeCreated,OrderCompleted - PascalCase
Event Structure¶
{
"event_id": "550e8400-e29b-41d4-a716-446655440000",
"event_type": "RecipeCreated",
"aggregate_id": "123e4567-e89b-12d3-a456-426614174000",
"aggregate_type": "Recipe",
"tenant_id": "11111111-1111-1111-1111-111111111111",
"timestamp": "2026-03-30T10:00:00Z",
"version": 1,
"payload": {
"name": "Bread",
"code": "BRD-001"
},
"metadata": {
"correlation_id": "22222222-2222-2222-2222-222222222222",
"causation_id": "33333333-3333-3333-3333-333333333333"
}
}
Transactional Outbox (Reliable Event Publishing)¶
Every module that publishes events writes them through a transactional outbox, never directly. This is non-negotiable — at-least-once delivery is a foundational guarantee of the modular monolith, and dispatching an event straight from a command handler would lose it whenever the post-commit dispatch failed.
There is no message broker. Events are delivered in-process: the outbox row is the durable hand-off, the OutboxPoller is the relay, and the EventDispatcher invokes the @on_event handlers. Kafka is deferred until a module is extracted to its own service (see § Forward migration below).
How it works¶
- The command handler writes the aggregate change and an
outboxrow in the same SQL transaction viaOutboxEventPublisher. Either both land or neither does. The publisher also issuesNOTIFY outbox_newso the poller wakes promptly. - The
OutboxPoller(src/shared/outbox/poller.py) claims a batch ofpendingrows withSELECT ... FOR UPDATE SKIP LOCKED(so any number of pollers can run safely), reconstructs eachDomainEventfrom the row, and hands it to theEventDispatcher. - The
EventDispatcher(src/shared/events/dispatcher.py) invokes every handler registered fortype(event)— each event dispatched in its own freshAsyncSession, so one handler's rollback can't poison the batch's marking transaction. - The poller marks each row
completed(all handlers returned) orfailed(a handler raised —error+retry_countrecorded), then commits. Completed rows are pruned later (see § Retention).
Between batches the poller waits on Postgres LISTEN outbox_new for low-latency wake-up, falling back to a configurable timeout so a missed NOTIFY still drains. Lifespan wiring (load_all_handlers() + the poller task) lives in main.py.
Shared outbox table¶
One table, shared by every module. Defined in src/shared/outbox/models.py::OutboxEvent (composes UUIDPkMixin, TenantScopedMixin, TimestampMixin — append-only, so no OptimisticLockMixin / AuditedByMixin).
Columns:
id UUID PRIMARY KEY -- from UUIDPkMixin
tenant_id UUID NOT NULL -- from TenantScopedMixin (+ index)
aggregate_type VARCHAR(50) NOT NULL -- e.g. "Item", "Recipe"
aggregate_id UUID NOT NULL
event_type VARCHAR(80) NOT NULL -- e.g. "ItemCreated"; selects the handler set
payload JSONB NOT NULL -- the event's `payload` field ONLY (model_dump, mode="json"), not the full envelope — envelope fields live in their own columns above
metadata JSONB NOT NULL -- correlation_id, causation_id, headers (Python attr: event_metadata)
status outbox_status NOT NULL DEFAULT 'pending' -- pending → completed | failed
retry_count INTEGER NOT NULL DEFAULT 0 -- bumped each time a row is marked failed
error VARCHAR(1000) NULL -- truncated str(exc) of the failing handler
processed_at TIMESTAMPTZ NULL -- set when the row reaches completed/failed
trace_context JSONB NULL -- W3C traceparent/tracestate captured at publish; parents the consumer span
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -- from TimestampMixin
updated_at TIMESTAMPTZ NULL -- from TimestampMixin; stays NULL on this append-only table
Indexes (declared in __table_args__ on the model — autogenerate picks them up):
ix_outbox_tenant_id(single column, fromTenantScopedMixin)ix_outbox_tenant_id_created_at— supports per-tenant scansix_outbox_unprocessed— partial index on(created_at) WHERE status = 'pending'; this is the index the poller's claim query rides, kept narrow to only the dispatch candidates.
Row level security is ENABLE + FORCE (so the table owner is also subject to the policy) and the tenant_isolation policy carries both USING and WITH CHECK clauses against current_setting('app.current_tenant')::uuid — see § Schema above for why this is the safer baseline for new tables. The poller drains all tenants' rows via the grinsystem_jobs (BYPASSRLS) role.
Application-side contract¶
Command handlers publish through OutboxEventPublisher (in src/shared/outbox/publisher.py), which only writes to the outbox table within the active session. The interface matches the EventPublisher protocol (async def publish(event: DomainEvent) -> None) so handlers stay transport-agnostic — the session is the transport.
Wiring: src/shared/api/deps.py exposes EventPublisherDep, an Annotated[EventPublisher, Depends(get_event_publisher)] alias that builds OutboxEventPublisher(session) per request. Command handlers depend on EventPublisherDep, never on a concrete class.
# command handler signature
async def handle(
cmd: CreateItemCommand,
session: DbSession,
publisher: EventPublisherDep,
) -> ItemRead: ...
Retention¶
Completed outbox rows are deleted by the prune CLI (python -m src.shared.outbox.prune); failed rows are preserved for investigation. Default retention is 90 days post-processed_at. The CLI connects via JOBS_DB_URL (the grinsystem_jobs role, which has BYPASSRLS) so the cross-tenant DELETE actually sees every row. See outbox-retention.md for the full operational runbook. For the dispatch loop itself, see outbox-dispatcher.md.
Forward migration¶
When a module is extracted to its own service, the in-process dispatcher target is swapped for a Kafka producer on the publish side and a Kafka consumer on the subscribe side; per-module topics (grinsystem.<context>.events) reappear at that point. The outbox pattern and the @on_event handler signatures stay identical — only the transport between "committed outbox row" and "handler invocation" changes.
Event Handlers (@on_event)¶
Cross-module reactions are written as event handlers, never as direct calls into another module. A handler subscribes to one concrete DomainEvent subclass and runs when the dispatcher delivers that event.
Where handlers live + autodiscovery¶
Put a module's handlers in src/modules/<module>/application/event_handlers.py. At startup main.py calls load_all_handlers() (src/shared/events/autoload.py), which walks src/modules/*/application/event_handlers.py and imports each one, triggering the decorator registrations. Modules without that file are silently skipped; a transitive ImportError inside an existing handler module re-raises so a typo is never swallowed.
The decorator¶
@on_event(EventType) (src/shared/events/decorator.py) registers the decorated coroutine into the default dispatcher for that exact event class. It returns the handler unchanged, so a test can await my_handler(event, session) directly.
# src/modules/warehouse/application/event_handlers.py
from sqlalchemy.ext.asyncio import AsyncSession
from src.shared.events import on_event
from src.modules.item_catalog.domain.events import ItemCreated
@on_event(ItemCreated)
async def project_item_into_warehouse(event: ItemCreated, session: AsyncSession) -> None:
# apply the side effect using the session the dispatcher passes in
...
Registration is keyed by exact class identity (type(event)): a handler on the broad DomainEvent envelope does NOT act as a wildcard. An event with no registered handler is marked completed with no side effects.
Per-handler session isolation¶
The dispatcher passes each handler a fresh AsyncSession minted by the poller per event — handlers never share the poller's lock-holding session. The handler does its work in that session; the poller commits it on success and rolls it back on failure. Do not open your own session inside a handler, and do not commit/rollback the passed session yourself.
When an event has multiple handlers they run concurrently under asyncio.gather with return_exceptions=False: the first failure propagates (and peers are cancelled), the poller marks the row failed, and the row is retried on a later batch. Write handlers so concurrent peers don't depend on each other's writes.
Idempotency expectation¶
Dispatch is at-least-once (a failed row is re-dispatched), so every handler must be idempotent — dedupe against a processed_events row in the same transaction as its side effects. See § Idempotency Keys → Handler-side idempotency.
Idempotency Keys¶
Two completely separate concerns, both mandatory:
A. Write-API idempotency (client → server)¶
Every state-mutating endpoint (POST / PUT / PATCH / DELETE) must accept the Idempotency-Key HTTP header (RFC draft, GitHub / Stripe / AWS standard). Clients supply an opaque token (UUIDv4 or anything ≤ 255 chars) per logical operation; the server caches (tenant_id, key) → (fingerprint, status, body) in Redis for 24 hours. A retry with the same key and the same request payload returns the cached response without re-executing the handler.
How to use — single decorator per endpoint:
from src.shared.idempotency import idempotent
@router.post("/items", status_code=201, response_model=ItemRead)
@idempotent
async def create_item(
cmd: CreateItemCommand,
handler: CreateItemHandlerDep,
) -> ItemRead:
return await handler.handle(cmd)
Order matters: @router.post(...) must sit above @idempotent so FastAPI registers the wrapped function. The decorator handles header validation, fingerprinting, Redis lookup, cached-response replay, conflict detection, and the post-handler cache write — endpoints do nothing else.
Where to apply it:
| Endpoint class | Decorator status |
|---|---|
| Money / inventory mutations (POST /sales-orders, POST /goods-receipts, POST /invoices) | Required |
| Cross-aggregate operations (POST /items/{id}/deactivate, POST /production-orders/{id}/approve) | Required |
| Simple CRUD writes (POST /items, PATCH /items/{id}, DELETE /items/{id}) | Required |
| GET / HEAD | Never — reads are idempotent by definition |
Error mapping:
| Condition | HTTP | Code |
|---|---|---|
| Header missing or > 255 chars | 422 | IDEMPOTENCY_KEY_REQUIRED |
| Same key, different request fingerprint | 409 | IDEMPOTENCY_KEY_REUSED_WITH_DIFFERENT_PAYLOAD |
| Same key, request still in flight | 409 | IDEMPOTENCY_KEY_REUSED_WITH_DIFFERENT_PAYLOAD (message indicates concurrent execution) |
| Same key, same fingerprint, cached entry present | original cached status_code |
replay — handler is not re-invoked |
Fingerprinting: sha256(METHOD + "\n" + PATH + "\n" + raw_body_bytes). Method and path are baked in, so the same key sent to a different endpoint 409s cleanly. Sized responses > 64 KB skip the cache (logged at WARNING) — the handler runs idempotently on the next retry.
Storage: Redis. Key: idem:{tenant_id}:{key}. Value: {"fingerprint": "<hex>", "status": <int|null>, "body": <jsonable|null>}. An in-flight placeholder (status null) is set with a 30-second TTL to serialise concurrent retries; on handler success it is overwritten with the final entry and a 24h TTL. On handler exception the placeholder is deleted so the client can retry cleanly.
Trade-off: the Redis write is not atomic with the DB write — a crash between DB commit and the Redis update lets a retry re-execute the handler. This is the industry-standard compromise (Stripe accepts it). If you need strict atomic dedupe for a specific endpoint, layer a domain-level uniqueness constraint (e.g. a client_request_id column on the aggregate) on top.
Where it sits in code: src/shared/idempotency/decorator.py (the decorator) and src/shared/idempotency/redis.py (the async client + lifespan disposal). Command handlers know nothing about it.
B. Handler-side idempotency¶
The outbox dispatch loop is at-least-once: a row whose handler raises is marked failed and re-dispatched on a later batch. So every @on_event handler must dedupe before applying side effects.
Storage: each handler dedupes against a processed_events table (in the consuming module's schema). One row per event the handler has applied:
event_id UUID PRIMARY KEY -- from DomainEvent.event_id
consumer_name VARCHAR(80) NOT NULL -- which handler processed it
processed_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
Pattern (inside the session the dispatcher passes in):
@on_event(ItemCreated)
async def project_item(event: ItemCreated, session: AsyncSession) -> None:
if await already_processed(event.event_id, session):
return
# ... apply side effects ...
await mark_processed(event.event_id, session)
The dedupe check + mark + side-effect all run in one transaction (the dispatcher commits the handler's session on success). A crash after side-effect-commit-before-mark is impossible because the mark is in the same transaction.
Not yet built. No module wires
@on_eventhandlers today, so there is no shared dedupe helper insrc/shared/events/yet. The first handler that lands owns introducing theprocessed_eventstable + a small shared helper (e.g.already_processed/mark_processed); this section is the convention it must follow.
Why both layers¶
The write-API key prevents client retries from creating duplicates. The handler key prevents redeliveries (re-dispatched failed rows, and broker redeliveries once a module is extracted) from creating duplicates. They protect against different failure modes and you need both.
Logging¶
Levels¶
| Level | Use |
|---|---|
| DEBUG | Development details |
| INFO | Business events |
| WARNING | Recoverable issues |
| ERROR | Failures requiring attention |
| CRITICAL | System failures |
Format¶
from src.shared.observability import get_logger
logger = get_logger(__name__)
# Good
logger.info(
"recipe_created",
recipe_id=str(recipe.id),
code=recipe.code,
)
# Bad
logger.info(f"Created recipe {recipe.id}")
tenant_id, user_id, trace_id, and span_id are already on the record via structlog.contextvars and the OTel logging instrumentor — don't pass them by hand.
What to Log¶
- Always: tenant_id, user_id, correlation_id
- Business events: entity IDs, status changes
- Never: passwords, tokens, PII
Security¶
Authentication¶
The identity module (not yet shipped) will issue and validate JWTs. Until then, src.shared.auth.current_user.get_current_user is a stub that returns a fixed dev user/tenant pulled from settings. Every request resolves to a CurrentUser(user_id, tenant_id, roles) regardless of which provider is in place.
Forward-compat invariant: when the identity module replaces get_current_user in-place with real JWT validation, endpoint code and the role guards do not change — they only ever read CurrentUser.roles. Token storage, refresh, and API-key issuance are owned by the future identity module.
Authorization¶
Authorization is permission-based. Endpoints declare what capability they gate (e.g. Permission.ITEM_CREATE), never which roles happen to have it today. Role-to-permission policy lives in one mapping; endpoint code is decoupled from it.
Primitives (all in src.shared.auth):
| Symbol | Purpose |
|---|---|
RoleName |
StrEnum of canonical roles stored in CurrentUser.roles (admin, manager, technologist, operator, warehouse_operator, sales, accountant, viewer). |
Permission |
StrEnum of <module>:<resource>:<action> capabilities. Every gated endpoint maps to one value here. |
PERMISSION_ROLES: Mapping[Permission, frozenset[RoleName]] |
Single source of truth for which roles grant which permission. ADMIN is never listed (wildcard — see below). A startup invariant check fails fast if any Permission value is missing a mapping entry. |
require_permission(p) |
The public way to gate an endpoint. Resolves p through PERMISSION_ROLES, admits any user whose roles satisfy the mapping (or any ADMIN). Raises InsufficientPermissionError on miss. |
require_roles({...}) |
Low-level primitive. Use only when the gate is genuinely a literal role set rather than a named capability. |
InsufficientPermissionError |
HTTP 403, code FORBIDDEN_INSUFFICIENT_PERMISSION, rendered through the standard error envelope. |
AnyAuthenticated |
Pre-built alias in src.shared.api.deps — same effect as CurrentUserDep but documents that an endpoint deliberately admits any authenticated user (e.g. /me). |
Rules:
- Gate every restricted endpoint with
Depends(require_permission(Permission.X)). Do not gate by raw role sets at the endpoint surface; that creates combinatorial alias names and couples endpoints to today's role-to-permission policy. RoleName.ADMINis a wildcard enforced inside the guard. Never listRoleName.ADMINinPERMISSION_ROLESentries — an emptyfrozenset()for a permission means "admin only".- Add the new
Permissionenum value and itsPERMISSION_ROLESentry in the same commit as the endpoint that needs it. The startup check guarantees the build fails immediately if you forget the mapping. - Permission strings follow
"<module>:<resource>:<action>"(e.g."item_catalog:item:create","manufacturing:bom:publish") so the registry stays greppable and unambiguous as it grows. - Tenant isolation is orthogonal and enforced by PostgreSQL RLS, not by role guards. See § Multi-tenancy.
Endpoint pattern:
from typing import Annotated
from fastapi import APIRouter, Depends
from src.shared.auth.current_user import CurrentUser
from src.shared.auth.permissions import Permission
from src.shared.auth.role_guards import require_permission
router = APIRouter(prefix="/items", tags=["item-catalog"])
@router.post("", status_code=201)
async def create_item(
body: CreateItemRequest,
repo: ItemRepoDep,
user: Annotated[CurrentUser, Depends(require_permission(Permission.ITEM_CREATE))],
) -> CreateItemResponse:
...
When the role-to-permission policy shifts (e.g. Sales gains item-create rights), only the one line in PERMISSION_ROLES changes — every endpoint that gates on ITEM_CREATE follows automatically.
Resource-ownership / ABAC checks (e.g. "this user can only see items in their tenant", "only the author can edit") are intentionally not in require_permission. RLS handles tenant scoping at the DB layer; per-aggregate ownership belongs in the command/query handler.
The companion /stats endpoint of any list resource shares the list endpoint's read permission — gate /items/stats with the same Permission.ITEM_READ as /items. Do not introduce a separate <RESOURCE>_STATS_READ permission; anyone who can see the list can see its aggregate. See § Stats endpoints.
Input Validation¶
- Pydantic for all inputs
- Validate at API boundary
- Sanitize before storage
OWASP Top 10¶
- Injection prevention (parameterized queries)
- Broken auth mitigation (JWT, secure storage)
- Sensitive data protection (encryption at rest)
- XXE prevention (disable XML)
- Broken access control (RBAC, RLS)
- Security misconfig (secure defaults)
- XSS prevention (output encoding)
- Insecure deserialization (strict schemas)
- Vulnerable dependencies (dependabot)
- Logging & monitoring (audit trail)
Observability¶
Full reference: docs/architecture/observability.md. Stack: OpenTelemetry → OTel Collector → SigNoz. The rules below are the minimum every module must follow.
What to import¶
from src.shared.observability import counter, get_logger, histogram, start_span, traced_handler
logger = get_logger(__name__)
get_logger(__name__)— bind a structlog logger named after the current module so each record carrieslogger=src.modules.<name>...(searchable / filterable in SigNoz). Tenant / user / trace IDs are already instructlog.contextvars, so every call automatically includes them.@traced_handler— applied to every Command/Query Handlerhandlemethod. Opens a span named{module}.{HandlerClass}and bindsmodule+command/queryto the log context.counter(name, ...),histogram(name, ...)— OTel metric helpers; declare as module-level singletons.start_span(name, **attrs)— context manager for manual spans when a step within a handler is worth measuring on its own.
What to log¶
| Situation | Level | Example |
|---|---|---|
| Domain state change succeeded | info |
logger.info("recipe_created", event="recipe_created", entity_id=str(recipe.id)) |
| Domain rule rejected the request | warning |
logger.warning("login_failed", email=command.email) then re-raise |
| Unexpected exception | error |
logger.error("unexpected_error", exc_info=True) |
| Local diagnostic | debug |
off in qa/prod |
Do not log on the happy path of Query Handlers — the auto-instrumented HTTP span already records duration and status. Do not log + swallow exceptions; log only at the boundary that decides to suppress.
What to trace¶
- Auto-instrumentation already covers FastAPI requests, SQLAlchemy/asyncpg statements, redis, httpx; the in-process outbox dispatcher emits its own consumer span per event.
- Add a manual span only for:
- A measurable in-process step inside a handler (e.g. multi-level BOM traversal).
- An external boundary that the SDK does not instrument.
@traced_handler
async def handle(self, command: PublishRecipeVersionCommand) -> ...:
recipe = await self._recipe_repo.get_by_id(command.recipe_id)
with start_span("manufacturing.calculate_allergens", recipe_id=str(recipe.id)):
allergens = recipe.calculate_allergens()
...
What to meter¶
Default to none. Add a metric only when: - You need an alertable rate or histogram independent of trace sampling. - A dashboard graph needs aggregation across millions of events.
Naming: grinsystem.{module}.{noun}_{unit}. Keep labels low-cardinality — tenant_id is fine; per-entity IDs are not.
Sensitive data¶
Never put passwords, tokens, JWTs, cookies, or Polish national IDs (pesel, nip, regon) into log fields. A structlog processor redacts a configured deny-list, but add new sensitive field names to the deny-list in src/shared/observability/logging.py in the same PR that introduces them — do not rely on call-site discipline.