ADR-068: Validation message key naming convention
Date: 2026-06-11 Authors: Jean-Francois Meyers Scope:
Granit.Validationresource + every module that owns validation messages (framework + granit-business + granit-iot)
Context
Section titled “Context”Validation localization keys grew four incompatible styles under one flat Validation: namespace:
| Family | Style | Examples |
|---|---|---|
| FluentValidation built-ins | {Rule}Validator | Validation:NotEmptyValidator, Validation:MinimumLengthValidator |
| Granit format/identifier validators | Invalid{Format} (mostly) | Validation:InvalidIban — but Validation:UrlMustBeHttps, Validation:CodeInvalidFormat break it |
| Pattern hints | Hints:{Code} | Validation:Hints:Alpha2Code |
| Domain rules | ad-hoc | CalendarRangeInverted, MaxBatchSize, ScopeRequired, PeriodSpecBoundsCode, DueAtMustBeAfterIssuedAt |
Nothing in the name tells you who owns the key, which category of rule it is, or what it constrains. Two failure modes followed:
- Domain messages leaked into the framework resource. When commercial modules moved to granit-business, their validation keys (Payments, Metering, Analytics, Parties merge, …) were left behind in
Granit.Validation, with no framework consumer. - The #2205 prefix rename (
Granit:Validation:*→Validation:*) was never propagated to the consuming repos. granit-business validators still emitGranit:Validation:*codes that match no key in the post-#2205 SPA dictionary, so those messages resolve to the raw code client-side.
Decision
Section titled “Decision”One deterministic shape, owner-first:
- Framework keys —
Validation:{Category}:{Rule},Category ∈ { Builtin, Format, Hint, Problem }:Validation:Builtin:NotEmpty(FV built-ins; drop the redundantValidatorsuffix)Validation:Format:Iban(format/identifier validators;Formatalready implies “invalid”)Validation:Hint:Alpha2Code(pattern hints)Validation:Problem:Title(the 422 problem-details title)
- Module-owned domain keys —
{Module}:Validation:{Rule}in the owning module’s resource, never inGranit.Validation:Payments:Validation:AmountOutOfRange,Parties:Validation:InvalidMergeChoice,Invoicing:Validation:DueAtAfterIssuedAt
Ruleis PascalCase from a closed constraint vocabulary:Invalid,Required,TooLong/TooShort,OutOfRange,Ordering,Empty,TooMany, …- Wire contract unchanged for built-ins. FluentValidation’s
ErrorCodestays{Rule}Validator; only the localization lookup key is remapped insideGranitErrorCodeLanguageManager. ForWithErrorCodeAndMessage, code == key == the value in the 422errorsmap, so the scheme is the wire contract there (a pre-1.0 breaking change — no[Obsolete]graduation).
Enforcement
Section titled “Enforcement”ValidationKeyConventionTests (architecture shard) asserts every Validation:* key matches Validation:(Builtin|Format|Hint|Problem):{PascalCase}. A PendingMigration set holds the keys still on the legacy scheme — the canonical, CI-tracked migration backlog. It shrinks to empty as keys migrate; a stale entry (migrated or removed) fails the test, and a new non-conforming key fails unless explicitly justified. Each consuming repo (granit-business) adds the sibling rule for its {Module}:Validation:* keys.
Consequences
Section titled “Consequences”- The migration is phased per package/shard (the framework forbids whole-solution builds), tracked by
PendingMigration:- Framework built-ins →
Builtin:, format →Format:, hints →Hint:,ProblemTitle→Problem:Title. - Relocate the ~20 domain leftovers from
Granit.Validationto their owning business modules; fix the staleGranit:Validation:*references there at the same time.
- Framework built-ins →
- Breaking for API clients that read the per-field
ErrorCode/message from the 422errorsmap forWithErrorCodeAndMessagerules. Built-in messages are unaffected on the wire. - Future keys are self-documenting (owner + category + constraint) and the framework resource stays restricted to genuinely framework-shipped validators.
Status
Section titled “Status”Accepted. Convention + architecture test landed; migration in progress (see PendingMigration).