ADR-007: Validation placement
Status: accepted
Context
Pydantic makes it tempting to put every rule in the request schema: rounds: int = Field(ge=1, le=20). Then the rule exists twice (schema and domain) or, worse, only in the
schema - and a second entry path (import job, CLI, another service) silently skips it.
Decision
- Pydantic validates shape: types, required fields, enum membership, discriminated unions. It rejects malformed JSON with FastAPI's native 422.
- The domain validates rules in
__post_init__and methods: ranges, invariants between fields, state transitions. It raises specificDomainErrors, mapped to 422 with a stableerrorname. - Schemas do not duplicate domain constraints, even easy ones (
min_length=1on the name is deliberately absent so that a blank name reachesInvalidTournamentName). - Value objects may be constructed in the HTTP adapter (
CreateTournamentRequest.to_command()buildsPhases), so aDomainErrorcan be raised before the use case runs. That is fine: the rule still lives in one place, the domain, and the error mapping is the same.
Consequences
- One source of truth per rule. A rule change is one edit and one domain test.
- Clients get two flavours of 422: Pydantic's
detaillist for shape, the error envelope for rules. Both are documented in OpenAPI viaresponses=. - Some errors that Pydantic could catch at parse time are caught a few microseconds later by the domain. Nobody notices.
When to revisit
If you need field-level error locations for rule violations in a form-heavy UI, have the
domain raise errors that carry a field attribute and extend shared/http/errors.py to
include it. Keep the rule in the domain.
Proof
No executable proof: where validation lives is a convention. The rules themselves are covered by the domain tests of the feature; the HTTP mapping of both 422 flavours by its HTTP tests.