Record Mapping Basics

One interface, both directions: a total build from domain to DTO, and an accumulating, located parse back.

Every service boundary maps between a rich domain record and a flat wire DTO. Hand-written mappers drift; reflection-based mappers fail at runtime and know nothing about validation. @GenerateMapping derives the mapping at compile time, reflection-free, from an interface you own, and because the fallible direction returns Validated<NonEmptyList<FieldError>, Domain>, a bad DTO reports every bad field at once, each located by name.

What You'll Learn

  • Declaring a mapping as a MappingSpec<Domain, Wire> interface and using the generated Impl
  • Why build is total while parse is fallible and accumulating
  • The null doctrine: how every null on the wire becomes a located FieldError, never an exception
  • Converting type-differing fields with ValidatedPrism leaves
  • Renaming components with @MapField, and computing wire-only fields with derived getters

See Example Code

The code on this page is RecordMappingBook.java - the page includes it directly, so it is compiled and run by the build.

GenerateMappingExample.java

The whole declaration is an empty interface naming the pair:

record Person(String name, int age) {}

record PersonDto(String name, int age) {}

@GenerateMapping
interface PersonMapping extends MappingSpec<Person, PersonDto> {}


    Person person = new Person("Ada", 36);

    // Same-named, same-typed components match automatically:
    PersonDto dto = PersonMappingImpl.INSTANCE.build(person); // total
    Validated<NonEmptyList<FieldError>, Person> back =
        PersonMappingImpl.INSTANCE.parse(dto); // accumulating, located

The two directions have different shapes, and that asymmetry runs through the whole chapter:

   build : Domain ──▶ DTO      total, always succeeds
   parse : DTO ──▶ Domain      fallible, reports every bad field at once
                               Validated<NonEmptyList<FieldError>, Domain>

The generated class is <Spec>Impl beside the spec, used through its INSTANCE constant. A spec nested in an outer class joins the enclosing simple names: Shop.CustomerMapping generates ShopCustomerMappingImpl.


One null doctrine, both wire shapes

A JSON binder leaves a missing property null, on a record component just as on an unset bean property. So every reference-typed parse read is null-guarded: a null component is a located FieldError (must not be null) that accumulates with every other bad field, never an exception, and it locates through nesting (customer.name: must not be null). A null never reaches a leaf's prism.

The doctrine reaches inside containers too, identity-copied ones included:

  • A null element or map value locates by its index or key (emails.1: must not be null), whether the container lifts through a leaf (parseAll/parseValues) or copies by identity. The index is a plain positional segment, matching the map-key grammar.
  • An identity container still copies by reference; the scan only locates nulls, it never rebuilds.
  • A null container component is guarded like any reference read (emails: must not be null).

What stays the caller's error (NullPointerException), by contract: a null wire itself, a null map key (a structurally broken map, not a wrong value), and calling the bulk forms directly with a null list or map.

Absence-as-a-meaning remains exclusively the sparse UpdateSpec tier's: a record cannot express absence, it can only be wrong.

At the Spring boundary: one 422, every bad field by path

In a Spring controller the parse result needs no wrapping: return it as-is and hkj-spring renders an Invalid as one 422 Unprocessable Content response listing every located FieldError by path. See the 422 leg.


Validated leaves

A leaf is the conversion at a single field: the point where the mapping stops delegating and one wire value becomes one domain value. Where the two sides differ in type, the leaf is a ValidatedPrism, supplied as a zero-parameter default method named after the domain component:

record Customer(String name, EmailAddress email) {}

record CustomerDto(String name, String email) {}

@GenerateMapping
interface CustomerMapping extends MappingSpec<Customer, CustomerDto> {
  default ValidatedPrism<String, EmailAddress> email() { // wire first, domain second
    return EmailCodecs.EMAIL;
  }
}


    CustomerMappingImpl.INSTANCE.parse(new CustomerDto("Bob", "not-an-email"));
    // Invalid(NonEmptyList[email: not an email address])

The stock conversion families (identifiers, dates, enums, money) need no hand-written leaves at all; Standard Codecs covers them.

A leaf beats an identity match

An explicit leaf wins even when the two component types are identical, so a ValidatedPrism<String, String> can validate a field the types alone would copy verbatim. Validate, not normalise: a parse that trims or case-folds accepts a spelling its build cannot reproduce, which breaks the section law (an accepted wire value must rebuild to exactly itself).


Renames: @MapField

A rename is an abstract method named after the domain component, with to naming the wire component:

record PersonCardDto(String fullName, int age) {}

@GenerateMapping
interface PersonCardMapping extends MappingSpec<Person, PersonCardDto> {
  @MapField(to = "fullName")
  String name(); // Person.name <-> PersonCardDto.fullName
}

Each wire component takes exactly one domain source; colliding renames are compile errors, not surprises.

Located error paths use domain component names, renames included: a wire sending fullName gets its errors at name. Every path in the system (nesting, containers, the sparse tier's labels) is domain-named, so paths stay mutually consistent and stable under wire refactors; a client mapping errors back onto its own payload keys must apply the rename in reverse.


Derived wire fields

A wire component with no domain counterpart can be computed from the whole domain value. Declare a zero-parameter default method named after the wire component, returning Getter<Domain, WireComponentType>:

record Profile(String first, String last) {}

record ProfileDto(String first, String last, String displayName) {}

@GenerateMapping
interface ProfileMapping extends MappingSpec<Profile, ProfileDto> {
  default Getter<Profile, String> displayName() {
    return Getter.of(p -> p.first() + " " + p.last());
  }
}


    ProfileMappingImpl.INSTANCE.build(new Profile("Ada", "Lovelace"));
    // ProfileDto[first=Ada, last=Lovelace, displayName=Ada Lovelace]

The two directions are asymmetric: build computes the derived component, parse throws it away.

  build : fills the derived component from the whole domain value
  ────────────────────────────────────────────────────────────────
  Profile(first, last) ──▶ ProfileDto(first, last, displayName)
                                                   ▲
             displayName() : Getter<Profile,String>│  first + " " + last
                                                   └── computed, not copied

  parse : ignores the derived component (it is derivable)
  ────────────────────────────────────────────────────────────────
  ProfileDto(first, last, displayName) ──▶ Valid(Profile(first, last))
                          └── displayName dropped, never read

build fills the component by applying the getter to the whole domain value. parse ignores it: the data is derivable, so parse stays accumulating over the remaining components. (A mapping whose only extra is a derived field is total-parse: no well-formed wire value can fail it. The null doctrine's guards still apply, so a hostile null component is a located invalid, exactly as everywhere else.)

The optic is a Getter because a derived field is single-valued, exactly one focus computed from the whole domain value. A Fold, with its zero-to-many focuses, has no single-component meaning here.

How the two default families are told apart

Leaves are named after domain components and return ValidatedPrism; derived fields are named after wire-only components and return Getter. The processor matches the two differently:

  • A zero-parameter default returning Getter is always claimed as a derived field, and validated as one. So give getter-shaped utility helpers a parameter or a different return type, or they will be mistaken for derived fields.
  • A default returning ValidatedPrism is matched by name against the domain's components, and a locally declared leaf must match: an unmatched local leaf is a compile error with a nearest-name hint (leaf 'emial' names no component of Customer. Did you mean 'email()'?), because a silently inert leaf would silently stop validating that field. Prism-returning helpers belong in private or static methods, which are never leaf-shaped.
  • Inherited mix-in leaves that match nothing stay inert by design: a shared vocabulary may carry leaves for components only some extending specs have.
  • On a sealed mapping, locally declared leaves and derived fields are rejected outright (a dispatch has no components); inherited vocabulary stays inert there too.

Four shapes are rejected, each with a what/why/fix diagnostic: a Getter named after a domain component (ambiguous with a leaf); a Getter naming nothing on the wire; a Getter with the wrong type arguments; and a @MapField rename targeting a component a derived field already fills.

Derived fields and the emission tiers. A spec with any derived field never emits asIso(): the wire round trip recomputes the derived component, so it is an identity only for wire values that were already consistent. Combining a derived field with a projection (a wire otherwise smaller than the domain) is rejected too, because the projection's asLens() write-back could never honour a component that build recomputes. The Emission Tiers is the full story.


Key Takeaways

  • A mapping is an interface you own: @GenerateMapping on a MappingSpec<Domain, Wire> generates <Spec>Impl with build and parse
  • Two directions, two shapes: build is total; parse reports every bad field at once, each located by a domain-named path
  • Null is located, never thrown: one doctrine across both wire shapes and inside containers; only a null wire itself stays the caller's error
  • Leaves convert, renames rename, getters derive: ValidatedPrism leaves for type-differing fields, @MapField for names, Getter defaults for wire-only fields

See Also


Previous: Mapping at the Boundary Next: Standard Codecs and Shared Vocabulary