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 email can hold " FOO"; nothing
    stops it.
  • Nowhere to hang behavior. “Give me the email domain”, “give me the WhatsApp
    wa_id for 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 record whose 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 AttributeConverter annotated @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 primitiveUser.email is an Email,
    WhatsAppLink.phone is a PhoneNumber.

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;
    methods waId() (Meta’s id form) and waIdVariants() (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).
  • 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.