ADR-0013: Value objects for domain primitives — self-validating, with a converter
- Status: Accepted
- Date: 2026-06-10
- Scope: core (any backend with a domain layer)
Context
Some domain values are “just a String/BigDecimal” on the surface but actually
carry a canonical form and validity rules: an email (lower-cased, RFC-ish
shape), a phone number (E.164 digits), money, a CPF/CNPJ, a postal code. Modelling
them as raw primitives has three costs:
- Normalization and validation scatter. Every use case and DTO that accepts the
value re-trims, re-lowercases, re-checks the format — or forgets to. - Invalid values can exist. A raw
String emailcan hold" FOO"; nothing
stops it. - Nowhere to hang behavior. “Give me the email domain”, “give me the WhatsApp
wa_idfor this number”, “what are the Brazilian 9th-digit variants” have no home,
so they leak into services as static helpers.
This is the value-type counterpart to 0003-ddd-intent-methods (entities own
entity invariants): a value should own its own validity and behavior.
Decision
Model such values as value objects in domain/vo/:
- A
recordwhose compact constructor normalizes, then validates — so an
invalid instance cannot be constructed. Fail fast at the boundary of the type,
not deep in a use case. - Value-specific behavior lives on the VO, not in services
(Email.getDomain();PhoneNumber.waId(),PhoneNumber.waIdVariants()). - Immutable, value-equality (records give both).
- Persisted via a JPA
AttributeConverterannotated@Converter(autoApply = true),
so entities declare the field as the VO type while the column stays a plain scalar.
Adopting a VO for an existing column needs no migration, and reads of legacy
rows are normalized on the way in. - Entities hold the VO, never the primitive —
User.emailis anEmail,
WhatsAppLink.phoneis aPhoneNumber.
Validation vs. user-facing errors
The VO constructor throws IllegalArgumentException — that is a structural
backstop, not the UX layer. Friendly 4xx messages are still produced at the
boundary: bean-validation annotations on the request DTO (@Email, @Pattern)
or a ValidationStrategy. The VO guarantees no invalid value reaches the domain
even if a boundary check is missing; the boundary guarantees a nice message.
Examples (canonical)
Email— compact constructor lower-cases/trims and regex-validates; methods
getDomain(),getLocalPart();EmailConverter(autoApply).PhoneNumber— normalizes to E.164 (++ digits), validates the digit count;
methodswaId()(Meta’s id form) andwaIdVariants()(Brazilian 9th-digit
matching);PhoneNumberConverter(autoApply). See the Expenses WhatsApp ADR-0011.
When to use / when not
Use a VO when the value has a canonical form, validity rules, or behavior, or
recurs across the domain. Do not wrap every string — a free-text name or
description with no rules stays a String. A VO with an empty constructor and no
methods is a smell; that value didn’t need one.
Consequences
- Normalization + validation are defined once, at the type.
- “Impossible to hold an invalid value” — entire classes of guard code disappear.
- A natural home for value behavior, keeping services about orchestration.
- Zero-migration adoption and legacy-value healing via the converter.
- Cost: a small class + converter per value; don’t over-apply (see above).
Related
- 0003-ddd-intent-methods — entities own entity invariants; VOs own value
invariants. The pair makes the domain layer self-protecting end to end. - 0011-dto-design-rules — DTOs are wire contracts at the boundary; VOs are
domain types. A DTO field may be a primitive that maps to a VO in the entity. - Frontend ADR-0003 (masked inputs for formattable values) — the client-side analog:
the same “formattable value” gets a display mask in the UI and a VO in the domain.