Record Mapping

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

  • How @GenerateMapping derives a compile-time, reflection-free mapper from an interface you own: a total build and an accumulating, field-located parse
  • Handling type-differing fields with ValidatedPrism leaves, renaming components with @MapField, and how nesting, containers, and recursion compose into dotted error paths
  • Dispatching a mapping over two sealed hierarchies, exhaustively in both directions
  • Reading the emission tiers so the generated surface (asIso, asLens, the validated patch, asValidatedPrism) only ever offers what the field correspondences can lawfully support
  • Assembling one target from several sources with @GenerateMerge
  • Typing an error's diagnostic context, and retiring the untyped Map<String, Object>, with @GenerateErrorEnvelope

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

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 page:

   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 (tags.1: must not be null); 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. What stays the caller's error (NullPointerException): a null wire itself, and a null map key (a structurally broken map, not a wrong value); a null container component is guarded like any reference read (emails: must not be null); only calling the bulk forms directly with a null list or map is the caller's error. 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: the "leg" is the response route an all-FieldError payload travels at the Spring boundary, in the railway sense.


Validated leaves

Where the two sides differ in type, the boundary conversion 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])

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 or normalise a field the types alone would copy verbatim.


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 total and accumulating over the remaining components. (A mapping whose only extra is a derived field is total-parse: the accumulating parse still runs, it simply cannot fail.)

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;
  • 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.


Shared vocabulary: mix-in interfaces

The same rename or the same leaf tends to recur across an API's specs: every wire calls it fullName, every email parses the same way. Move the shared members onto a plain interface and extend it alongside MappingSpec:

// Plain vocabulary - not a spec itself. Any spec whose records share these
// shapes extends it alongside MappingSpec.
interface ContactVocabulary {
  @MapField(to = "fullName")
  String name();

  default ValidatedPrism<String, EmailAddress> email() {
    return EmailCodecs.EMAIL;
  }
}

record Client(String name, EmailAddress email) {}

record ClientDto(String fullName, String email) {}

@GenerateMapping
interface ClientMapping extends ContactVocabulary, MappingSpec<Client, ClientDto> {}

record Supplier(String name, EmailAddress email, String phone) {}

record SupplierDto(String fullName, String email, String phone) {}

@GenerateMapping
interface SupplierMapping extends ContactVocabulary, MappingSpec<Supplier, SupplierDto> {}


    // One vocabulary, two mappings - the inherited rename and leaf apply to both:
    ClientMappingImpl.INSTANCE.parse(new ClientDto("Ada Lovelace", "ada@example.org"));
    SupplierMappingImpl.INSTANCE.parse(new SupplierDto("Acme Ltd", "sales@acme.example", "01"));

An inherited member counts exactly as if it were declared on the spec: renames, leaves and derived fields, collected across the whole hierarchy (a mix-in may extend further mix-ins, and a diamond counts once). Precedence is Java's own: a member re-declared on the spec (or on a nearer mix-in) hides the one it overrides, and conflicting inherited default methods are already a javac error before the processor runs. The one case javac leaves open, unrelated mix-ins both declaring the same abstract rename (override-equivalent abstracts may coexist, JLS 9.4.1), folds into a single rename when the targets agree and is rejected with a diagnostic naming both interfaces when they conflict. Interface static helpers are not inherited (JLS 8.4.8), so factory methods on a mix-in stay inert.

Two shapes are rejected, each naming the offender:

  • a mix-in that is itself a mapping spec (directly or transitively extends MappingSpec/UpdateSpec): a mix-in shares vocabulary, a spec generates an Impl, and inheriting one spec from another would conflate the two;
  • a generic mix-in: inherited member types are read as declared, and substituting them under an instantiation is not supported yet.

Diagnostics about an inherited member name its declaring interface, abstract method 'bogus' (inherited from 'BrokenVocabulary') is neither a rename nor a leaf, so the fix points at the right file. Mix-ins compose with the rest of the feature: threaded generic specs can extend (non-generic) mix-ins, and UpdateSpec mappings inherit vocabulary the same way. @GenerateMerge specs still declare everything directly.


Nesting, containers, and recursion

A component whose two sides are themselves mapped by another spec in the same compilation nests automatically, and failures compose into dotted paths:

record Invoice(String id, Customer customer) {}

record InvoiceDto(String id, CustomerDto customer) {}

@GenerateMapping
interface InvoiceMapping extends MappingSpec<Invoice, InvoiceDto> {}


    InvoiceMappingImpl.INSTANCE.parse(new InvoiceDto("INV-2", new CustomerDto("Bob", "nope")));
    // Invalid(NonEmptyList[customer.email: not an email address])

Containers lift the same way:

  • List and Optional components lift through the element's leaf or spec; each failing list element is located by its index, so a bad second element reports as emails.1 (customers.1.email through a nested spec).
  • Map components lift their values; keys pass through untouched, and each entry's failures are located by its key, so a bad value under key en reports as attributes.en.email.

Because nesting is delegation (each spec's Impl exposes asValidatedPrism(), so a whole mapping plugs in wherever a leaf does), recursion terminates by construction: a self-referential Tree(String value, List<Tree> children) maps with an empty spec and round-trips any finite tree.

Map keys are located by toString()

The rendered path uses each key's toString(), so a key containing a dot looks the same as deeper nesting, and two distinct keys whose renderings collide share a location. The structured FieldError path list stays exact regardless, holding the whole key as one segment, and every error is still reported.


Sealed hierarchies

A MappingSpec over two sealed interfaces dispatches over the permitted subtype pairs, one spec per pair, exhaustively in both directions:

sealed interface Payment permits Card, Bank {}

record Card(String pan) implements Payment {}

record Bank(String iban) implements Payment {}

sealed interface PaymentDto permits CardDto, BankDto {}

record CardDto(String pan) implements PaymentDto {}

record BankDto(String iban) implements PaymentDto {}

@GenerateMapping
interface CardMapping extends MappingSpec<Card, CardDto> {}

@GenerateMapping
interface BankMapping extends MappingSpec<Bank, BankDto> {}

@GenerateMapping
interface PaymentMapping extends MappingSpec<Payment, PaymentDto> {}


// generated PaymentMappingImpl.build:
//   return switch (domain) {
//     case Card v -> CardMappingImpl.INSTANCE.build(v);
//     case Bank v -> BankMappingImpl.INSTANCE.build(v);
//   };

A domain subtype without a spec, or a wire subtype nothing produces, is a compile error: the dispatch cannot be partial.


Generic records: concrete, threaded, and element-mapped

A generic record maps two ways. As a concrete instantiation, name the type arguments in the spec and every component classifies under that substitution, so the whole toolkit (leaves, nesting, containers, the null doctrine, index location) applies unchanged:

record Page<T>(List<T> items, int total) {}

record PageDto<T>(List<T> items, int total) {}

@GenerateMapping
interface CustomerPageMapping extends MappingSpec<Page<Customer>, PageDto<CustomerDto>> {}


    CustomerPageMappingImpl.INSTANCE.parse(
        new PageDto<>(
            List.of(new CustomerDto("Ada", "ada@corp.example"), new CustomerDto("Bob", "nope")),
            2));
    // Invalid(NonEmptyList[items.1.email: not an email address])

An instantiated mapping registers like any other, so Report(Page<Customer> results) nests it automatically. A threaded spec nests too: a use site's type arguments unify against the spec's declared pair, so Report(Page<String> results) resolves PageMapping<T> as PageMappingImpl.<String>instance(), and a generic outer spec may thread its own variable straight through.

As a threaded spec, declare the spec generic in its own type parameters and one mapping serves every instantiation. Same-variable elements copy by identity under the null-element scan (a null element is items.1: must not be null, never a smuggled null), and the whole surface (build, parse, asIso on a lossless pair) is generic:

@GenerateMapping
interface PageMapping<T> extends MappingSpec<Page<T>, PageDto<T>> {}

// one Impl for every T: PageMappingImpl.<String>instance(), .<Integer>instance(), ...


    // One generic Impl serves every instantiation; identity elements copy through.
    Page<String> tags = new Page<>(List.of("fp", "hkt"), 2);
    PageDto<String> tagsDto = PageMappingImpl.<String>instance().build(tags);
    Validated<NonEmptyList<FieldError>, Page<Integer>> counts =
        PageMappingImpl.<Integer>instance().parse(new PageDto<>(List.of(1, 2, 3), 3));

A generic Impl cannot carry a typed static INSTANCE, so it follows the library's generic-singleton convention (EitherMonad.instance()): one stateless cached instance behind PageMappingImpl.instance(). Multi-parameter and bounded specs thread too (ResultMapping<E, A>, RankedMapping<T extends Number>), and a same-typed default leaf (ValidatedPrism<T, T>) still routes elements.

In assignment context the witness is inferred, so plain instance() reads naturally; the explicit PageMappingImpl.<String>instance() form is only needed where Java cannot infer:

PageMapping<String> tags = PageMappingImpl.instance();   // witness inferred

The three access shapes are one rule, not three conventions: how much state does the Impl carry?

Spec shapeAccessWhy
ConcreteXImpl.INSTANCEstateless, monomorphic: a plain constant
Threaded genericXImpl.<T>instance()stateless but generic: a typed constant is impossible, so the cached singleton sits behind a generic accessor (the EitherMonad.instance() convention)
Element-mappedXImpl.of(prisms)carries its leaf prisms as state: every call is a fresh, immutable instance

The third form is element-mapped: thread the two sides under different variables (Page<T> ↔ PageDto<TDto>) and declare the element mapping as an abstract leaf. Nothing on the spec can parse a TDto into a T, so the generated Impl defers it: each abstract leaf becomes a constructor-supplied field behind a public of(...) factory, one ValidatedPrism per leaf in declaration order:

// The element mapping is deliberately open: an abstract leaf, supplied at of(...) time.
@GenerateMapping
interface CodecPageMapping<T, TDto> extends MappingSpec<Page<T>, PageDto<TDto>> {
  ValidatedPrism<TDto, T> items();
}


    // One spec, any element codec: each abstract leaf arrives as a prism through of(...).
    Validated<NonEmptyList<FieldError>, Page<EmailAddress>> mail =
        CodecPageMappingImpl.of(EmailCodecs.EMAIL)
            .parse(new PageDto<>(List.of("ada@example.org", "nope"), 2));
    // Invalid(NonEmptyList[items.1: not an email address])

The Impl carries the prisms as state, so there is no singleton in either spelling: every of(...) call is a fresh, immutable instance. Element-mapped mappings nest as compositions: a use site whose pair unifies against one resolves each element pair in turn, through a leaf on the using spec named after the component (single-leaf specs; a spec with several abstract leaves resolves each pair through the registry), or recursively through another registered mapping, and emits CodecPageMappingImpl.of(entries()).asValidatedPrism() in place. Failures locate through the whole composed path (entries.items.1: not an email address); an unresolvable element pair is a compile error naming the pair and both levers.

Boundaries: generic mappings are record-to-record only (bean-shaped wires and UpdateSpec mappings stay concrete); raw uses (including raw nested arguments) and wildcards are diagnosed, while array arguments (Page<String[]>) are concrete, map fine, and unify structurally at nested use sites. An abstract leaf belongs to a generic spec: on a concrete or sealed one it is diagnosed, since nothing defers its parser.


The emission tiers: truthful types

The field correspondences select what the Impl can lawfully offer; nothing is fabricated:

Spec shapeGenerated surface
All components identity-matched (lossless)build, guarded parse, asIso()
Any fallible leaf, nested spec or derived fieldbuild, accumulating parse, no asIso
Wire record with fewer components, all identity (lossy projection)build + asLens() whose set writes the projected components back, no parse (the dropped components cannot be reconstructed)
Wire record with fewer components and any fallible correspondencebuild + a validated patch(domain, wire) write-back, no asLens and no parse, below
Every parse-capable mappingasValidatedPrism(): the mapping as a leaf, so it nests and lifts
A spec extending UpdateSpec (opt-in, bean wire)only updateFrom(Wire): a sparse PATCH fold, below

Two honesty notes on the lossless row. "Guarded" because even a lossless record parse can fail on a hostile binding (a null reference component, or a null element inside an identity container, is a located invalid); the parse-iso coherence law is scoped accordingly. And asIso().reverseGet is a second, unguarded wire-to-domain direction: it exists for lawful in-memory round trips, so never feed a freshly bound wire to reverseGet; locating its nulls is parse's job.

record Employee(String name, String department, int age) {}

record EmployeeCardDto(String name, String department) {}

@GenerateMapping
interface EmployeeCardMapping extends MappingSpec<Employee, EmployeeCardDto> {}


    Employee employee = new Employee("Ada", "Research", 36);
    Lens<Employee, EmployeeCardDto> badge = EmployeeCardMappingImpl.INSTANCE.asLens();
    Employee moved = badge.set(new EmployeeCardDto("Ada", "Platform"), employee);
// department written back, age kept: a lawful lens, not a fake inverse

Law-checked, in the repo and in your tests

"Lawfully offer" is verified, not promised: every emission tier above (lossless iso, projection lens, fallible leaf, nested spec, List/Optional/Map lifting, sealed dispatch, derived fields) is compiled and law-checked in the Higher-Kinded-J build itself, against the published hkj-test law harness. Your own specs get the same guarantee with one call from a test, where hkj-test lives:

import org.higherkindedj.optics.laws.MappingLaws;

    MappingLaws.assertMappingLaws(
        CustomerMappingImpl.INSTANCE.asValidatedPrism(),
        new CustomerDto("Ada", "ada@example.org"), // parses
        new CustomerDto("Bob", "not-an-email")); // must not parse

The overloads follow the tiers:

  • Lossless mapping: pass asIso() plus asValidatedPrism() to check the iso laws, both round trips, and the coherence between the two surfaces.
  • Projection: pass asLens() with a domain value and two wire values.
  • Validated patch (leaf-carrying projection): pass the patch and build method references, a domain value, and a parsing and a non-parsing wire value (below).
  • Fallible tier: pass asValidatedPrism() with a parsing and a non-parsing wire value.
  • Derived-field (total-parse) mapping: build recomputes what parse ignores, so only the non-derived components round-trip. The domain-sample overload assertMappingLaws(prism, domainValue) asserts exactly that and nothing stronger.
  • Sparse-update (UpdateSpec) mapping: pass the updateFrom method reference, a domain value, and an all-absent, a valid and an invalid wire to check the identity, idempotence and validation laws (below).

A spec with a derived field and a fallible leaf is better served by the fallible overload, given a parseable wire value whose derived components match what build would produce (this keeps the no-parse check). Reserve the domain-sample overload for total-parse mappings, where no wire value can fail.

Mapping types you don't own

The annotation sits on your spec interface, never on the mapped types, so third-party records, sealed hierarchies, and bean-shaped DTOs from compiled libraries map without being annotatable: interface VendorOrderMapping extends MappingSpec<com.vendor.OrderRecord, OrderDto> {} works today. Bean-shaped wire types (getter/setter DTOs) are covered too; see Bean-shaped wire targets below.


Leaf-carrying projections: the validated patch

A projection that also validates or normalises a field (a leaf on a projected component) has no lawful total lens: the write-back can fail. Instead of refusing to generate, the mapping emits the validated patch tier: the total build stays, and the write-back returns Validated:

record Subscriber(String id, EmailAddress email, int age) {}

record SubscriberDetailsDto(String email, int age) {}

@GenerateMapping
interface SubscriberDetailsMapping extends MappingSpec<Subscriber, SubscriberDetailsDto> {
  default ValidatedPrism<String, EmailAddress> email() {
    return ValidatedPrism.of(
        raw ->
            raw.contains("@")
                ? Validated.validNel(new EmailAddress(raw))
                : Validated.invalidNel(FieldError.of("not an email address")),
        EmailAddress::value);
  }
}

patch(domain, wire) writes every projected component onto the domain, validating each one: every bad field is reported at once, located under its component name, and the unprojected components are read from the domain argument, so they survive untouched by construction:

    Subscriber subscriber = new Subscriber("7", new EmailAddress("ada@corp.example"), 36);

    // The projected components validate and write back; the unprojected id survives untouched.
    Validated<NonEmptyList<FieldError>, Subscriber> renewed =
        SubscriberDetailsMappingImpl.INSTANCE.patch(
            subscriber, new SubscriberDetailsDto("grace@corp.example", 37));
    // Valid(Subscriber[id=7, email=EmailAddress[value=grace@corp.example], age=37])

    // Dense semantics: every projected field applies - a null is a located error, never absence.
    SubscriberDetailsMappingImpl.INSTANCE.patch(subscriber, new SubscriberDetailsDto(null, 37));
    // Invalid(NonEmptyList[email: must not be null])

Dense, not sparse: patch is the opposite of updateFrom

patch applies every projected component: a null reference read becomes a located FieldError (must not be null), never "leave unchanged". The REST-PATCH contract (null means absent, keep the current value) is the sparse UpdateSpec tier on a bean wire; this tier is its dense, record-shaped complement for writing a validated sub-view onto a bigger record.

Everything the full tier resolves is available on the projected components: explicit leaves (beating identity, so a ValidatedPrism<X, X> can normalise), nested specs (failures compose into dotted paths), and List/Optional/Map lifting. Nulls locate through the nesting too: a nested wire value delegates to the nested spec's parse, whose reference legs carry the same guard, so patch(customer, new CustomerPatchDto(new AddressDto(null))) reports address.zip: must not be null instead of throwing. Only derived fields stay rejected, and the wire shares parse's 16-component ceiling. At the Spring boundary the result is already the 422 leg's shape: return it as-is. Like every tier, this one is law-checked:

    MappingLaws.assertMappingLaws(
        SubscriberDetailsMappingImpl.INSTANCE::patch,
        SubscriberDetailsMappingImpl.INSTANCE::build,
        new Subscriber("7", new EmailAddress("ada@example.org"), 36), // the current value
        new SubscriberDetailsDto("grace@example.org", 41), // parses and changes the domain
        new SubscriberDetailsDto("not-an-email", 36)); // located failure

The patch laws are projection identity (patch(d, build(d)) == Valid(d)), idempotence, and located validation. build after patch is deliberately not a law: a normalising leaf rewrites the wire form by design, the same weakening as the fallible full tier.


Bean-shaped wire targets

The wire side need not be a record. A bean (a mutable class with a no-args constructor and getters/setters, or an immutable one with a builder) maps the same way, with the same features (renames, leaves, derived fields, container lifting, nesting). Only how the wire is read and written changes: build fills through setters or a builder, and parse reads through getters.

// A generated, mutable getter/setter DTO - not a record, so not annotatable. The spec still sits
// on your interface, never on the bean, so a third-party bean maps without being touched.
class ContactBean {
  private String name;
  private String email;

  public String getName() {
    return name;
  }

  public void setName(String name) {
    this.name = name;
  }

  public String getEmail() {
    return email;
  }

  public void setEmail(String email) {
    this.email = email;
  }
}

@GenerateMapping
interface ContactMapping extends MappingSpec<Customer, ContactBean> {
  // build() writes through setters; parse() reads through getters, null-guarded and located.
  default ValidatedPrism<String, EmailAddress> email() {
    return EmailCodecs.EMAIL;
  }
}

    Customer ada = new Customer("Ada", new EmailAddress("ada@corp.example"));
    ContactBean bean =
        ContactMappingImpl.INSTANCE.build(ada); // new ContactBean(); setName; setEmail
    Validated<NonEmptyList<FieldError>, Customer> fromBean =
        ContactMappingImpl.INSTANCE.parse(bean);
    // A null bean property parses to a located FieldError, e.g. [email: must not be null].

The design decisions worth knowing:

  • Null is located, never thrown — like every wire. The null guard is universal (one doctrine, both shapes), so a null property read becomes a located FieldError (must not be null) exactly as on a record wire. What is bean-specific is why nulls are expected at all: an unset property is a representable, ordinary state of a mutable bean, not just a hostile binding.
  • Honest tiers. Because an unset property is ordinary, a bean's guarded reference reads count as fallible and the mapping withholds asIso() automatically; an all-primitive bean (whose reads can never be null) still earns it. A record wire's guards exist for hostile bindings only, so a lossless record mapping keeps asIso(), with the parse-iso coherence law scoped to wires whose reference components are non-null. Nesting is unaffected: a bean mapping exposes asValidatedPrism() like any other, so record specs nest it and containers lift it.
  • Construction strategy is detected from the bean's shape, tried in order: a public no-args constructor with setX setters (and, for a getter-only List, the JAXB convention getItems().addAll(...)); then a static builder()/newBuilder() whose setters fill it and whose build() yields the wire. A bean that fits neither gets a what/why/fix diagnostic.
  • Optionality bridges through null. Beans never declare Optional, so a domain Optional<T> maps to a nullable bean property T: empty bridges to absent (build skips the write, leaving the property unset; parse reads Optional.ofNullable(...)), and a present value still validates through its leaf.
  • The domain stays a record. parse assembles the domain through its canonical constructor, so only the wire may be bean-shaped; a bean domain gets a diagnostic.

Not yet: bean projections and read-only beans

A bean projection (a bean with fewer properties than the domain) with a reference property, and read-only (parse-only) or write-only (build-only) beans, are follow-ons. An all-primitive bean projection maps as a lawful asLens() today; a reference-typed one is reported with a pointer to the validated-patch tier, which ships for record wires; the bean flavour remains a follow-on.


Sparse PATCH write-back: UpdateSpec

A parse reads a null bean property as broken data - a located FieldError. But a REST PATCH body means the opposite: the client sends only the fields it wants to change, and every other property of the bound request arrives null, meaning not provided, leave unchanged. The two meanings of null are a property of the DTO's contract, not something the mapper can infer, so sparse semantics are an explicit opt-in: the spec extends UpdateSpec<Domain, Wire> instead of MappingSpec.

// A PATCH request bean. Here null means "not provided, leave unchanged" - the opposite of the bean
// parse above, where null is broken data. That contract is opted into by extending UpdateSpec.
class ContactPatchBean {
  private String name;
  private String email;

  public String getName() {
    return name;
  }

  public void setName(String name) {
    this.name = name;
  }

  public String getEmail() {
    return email;
  }

  public void setEmail(String email) {
    this.email = email;
  }
}

@GenerateMapping
interface ContactPatchMapping extends UpdateSpec<Customer, ContactPatchBean> {
  // Generates only updateFrom(ContactPatchBean) : Edits.Accumulated<Customer> - no build/parse/as*.
  // A present field is set (email parsed through its leaf, located on failure); an absent (null)
  // one is skipped, so the domain's current value survives.
  default ValidatedPrism<String, EmailAddress> email() {
    return EmailCodecs.EMAIL;
  }
}

The Impl exposes a single method, updateFrom(Wire) : Edits.Accumulated<Domain> - no build, parse, or as* tier (a sparse mapping is not a projection of information, and an all-null wire is valid, not a total parse). It folds the present properties into an Update<Domain>, leaving the absent ones alone:

    Customer current = new Customer("Ada", new EmailAddress("ada@corp.example"));

    ContactPatchBean patch = new ContactPatchBean();
    patch.setName("Ada Lovelace"); // email left null: not provided, keep the current one

    Edits.Accumulated<Customer> update = ContactPatchMappingImpl.INSTANCE.updateFrom(patch);
    Validated<NonEmptyList<FieldError>, Customer> patched = update.apply(current);
    // Valid(Customer[name=Ada Lovelace, email=ada@corp.example]) - only the name changed
  • Present and valid → the field is set, or parsed through its leaf, and folded in.
  • Present and invalid → a located FieldError, accumulating as usual: sparseness never weakens validation of what was sent. Edits.Accumulated also offers applyPath(current) to drop straight onto the validation railway, so a controller answers with every error at once instead of persisting a partial write (an all-FieldError payload takes the 422 leg: hkj.web.validation-field-error-status, default 422).
  • Absent (null) → skipped; the domain's current value survives.

The return type is exactly what a hand-written Edits.accumulate(...) PATCH builder produces, so the two compose and the same consumption story (apply, applyPath, toValidated) carries over.

The rules that keep the contract honest:

  • A primitive wire property is rejected. A primitive is always present (its default), so it can never carry the null-as-absent signal; use the wrapper type (Integer, Boolean). This is forced, not a style choice: an all-absent body must fold to the identity update, which a primitive would break.
  • A domain Optional<T> component is rejected. Under null-as-absent, null already means "leave unchanged", so "set to empty" has no encoding (and a null-clears rule would be JSON Merge Patch's opposite contract). Model the field as a nested record or a sentinel instead.
  • A record wire is rejected. A record component is always present, so absence is inexpressible - sparse PATCH is a bean-only shape.
  • A sealed hierarchy is rejected, on either side: dispatch has no sparse meaning (an absent property cannot choose a subtype to patch).
  • A container whose elements need mapping (List<AddressDto> wire against List<Address> domain) is rejected: element lifting is a dense concern. The workaround the diagnostic offers is a whole-container leaf (ValidatedPrism<List<AddressDto>, List<Address>>), which replaces wholesale through your prism.
  • Coverage is one-sided. Every wire property maps to a domain component, but a domain component with no wire property is simply never changed: a PATCH DTO deliberately covers a subset.
  • A same-typed nested record, List or Map replaces wholesale through identity, elements included, unscanned: the sparse tier writes what was sent, it does not validate identity values. A nested record whose wire differs is patched wholesale through its own full mapping spec. Deep merge is out of scope.

The sparse tier is law-checked like every other, through the same MappingLaws harness:

    MappingLaws.assertMappingLaws(
        ContactPatchMappingImpl.INSTANCE::updateFrom,
        new Customer("Ada", new EmailAddress("ada@example.org")), // the current value
        patch(null, null), // all-absent   -> identity
        patch("Grace", "grace@example.org"), // present valid -> changes the domain
        patch(null, "not-an-email")); // present invalid -> located failure

Identity (an all-absent wire is the identity update), idempotence (applying the same patch twice equals applying it once - which holds because the generated edits set and parse, never modify), and validation (a present invalid field fails).


Merging several sources: @GenerateMerge

The forward-only sibling: assemble one target from several sources, declared entirely by the spec method's signature, no class literals, no inverse (truthful types):

record User(String name, String email) {}

record Account(String iban, int balance) {}

record Settings(boolean darkMode) {}

record Dashboard(String name, String iban, boolean darkMode) {}

@GenerateMerge
interface DashboardAssembly {
  Dashboard assemble(User user, Account account, Settings settings);
}


    Dashboard dashboard =
        DashboardAssemblyImpl.INSTANCE.assemble(
            new User("Ada", "ada@corp.example"),
            new Account("GB29-XXXX", 4200),
            new Settings(true));

Each target component fills from the one source with a same-named component: identity when the types match, through a ValidatedPrism leaf when they differ, or through a sibling @GenerateMapping spec (the customer below parses through CustomerMappingImpl, and failures locate as dotted paths):

record Wrapper(CustomerDto customer) {} // the wire side

record ProfileCard(String name, Customer customer) {} // the domain side

@GenerateMerge
interface ProfileCardAssembly {
  // ProfileCard.customer fills from Wrapper.customer through CustomerMapping,
  // so a bad email is: Invalid(NonEmptyList[customer.email: not an email address])
  Validated<NonEmptyList<FieldError>, ProfileCard> assemble(User user, Wrapper wrapper);
}

Ambiguity (two sources carrying the component) and unfilled components are compile errors, and the return type must tell the truth: fallible fills demand the Validated return; an identity-only merge must declare the plain target.

The fallible path carries the same null doctrine as parse: every reference-typed source-component read is null-guarded, so a null component is a located, accumulated FieldError (must not be null), never an exception — a null source argument stays the caller's NullPointerException. A plain-return merge is total by its declaration: nulls flow through to the target constructor exactly as build copies them. (The return type follows the fills, so the guard cannot be bought by declaration alone — an identity-only merge that wants it should add a normalising ValidatedPrism<X, X> leaf, which makes the merge fallible and brings the Validated return with it.)


Generating error envelopes: @GenerateErrorEnvelope

The third generator in the family targets the other end of the boundary: the typed domain error a fallible mapping produces. A sealed error hierarchy re-declares the same envelope (code, message, timestamp, context) on every variant, and context is usually an untyped Map<String, Object>. @GenerateErrorEnvelope supplies the envelope and types the context, so each variant declares only its domain-specific components plus one ErrorEnvelope<C> component:

// The context is records-as-schema: nullable components, an all-absent default.
record OrderErrorContext(@Nullable OrderId orderId, @Nullable TraceId traceId) {}

@GenerateErrorEnvelope
sealed interface OrderError {
  ErrorEnvelope<OrderErrorContext> envelope(); // declared once

  // A one-line default so the generated wither reads as an instance method.
  default OrderError editContext(UnaryOperator<OrderErrors.ContextBuilder> edit) {
    return OrderErrors.editContext(this, edit);
  }

  record OutOfStock(List<ProductId> products, ErrorEnvelope<OrderErrorContext> envelope)
      implements OrderError {}

  record PaymentDeclined(CardRef card, ErrorEnvelope<OrderErrorContext> envelope)
      implements OrderError {}
}

Two senses of 'context'

The typed context here is diagnostic metadata attached to an error value: a records-as-schema type such as OrderErrorContext. It is unrelated to the ErrorContext effect type, which is a composable IO-plus-Either computation. This page's context is data carried on an error; that one is a way of running effects.

For OrderError the processor generates a companion named OrderErrors with three pieces:

  • A factory per variant. code is the UPPER_SNAKE variant name and message its humanised form; the timestamp is read from a TimeSource, so an overload takes one explicitly and the convenience uses TimeSource.system().
  • A fluent context() builder over the context record's components.
  • An editContext(error, edit) wither that rebuilds the concrete variant through an exhaustive switch.

Add a one-line default so the wither reads as an instance method, and construction plus enrichment matches the shape you would hand-write:

    OrderError error =
        OrderErrors.outOfStock(products) // typed factory
            .editContext(
                ctx -> ctx.orderId(orderId).traceId(traceId)); // typed context, not map.put

The context type is discovered structurally from the ErrorEnvelope component's type argument, never a class literal, and every variant must agree on it. Three rules apply, each a what/why/fix diagnostic:

  • the hierarchy, its variants, and the context record must be non-generic;
  • permitted variants must be records; a nested sealed sub-hierarchy is rejected with a flatten-it fix, not recursed into;
  • the context record's components must be nullable reference types. The all-absent context holds null, so primitives are rejected at compile time; and because a null-rejecting compact constructor cannot be detected by the processor, keep the context a plain nullable data carrier.

Fine-grained or coarse variants?

The design choice is about the hierarchy, not the annotation.

  • Fine-grained (one variant per failure mode, each with its own typed fields, as in MarketError's FeedDisconnected / RiskLimitBreached / StaleData): the generated MarketErrors factories carry everything, and no hand-written construction remains.
  • Coarse (a variant grouping several codes, as in OrderError's CustomerError covering CUSTOMER_NOT_FOUND and CUSTOMER_SUSPENDED): suits a boundary whose downstream switch presents failures by category. One generated factory per variant derives only one code, so these variants keep a hand-written factory per code, each calling the canonical constructor with ErrorEnvelope.of(...) and the generated builder.

Either way the repeated envelope and the untyped Map<String, Object> are gone. Reach for fine-grained variants when each failure mode is genuinely distinct, and group them when a boundary treats a whole category uniformly.

Two verbs keep the two operations distinct: ErrorEnvelope.withContext(D) is the record wither that replaces the context (and may change its type), while the generated editContext(error, edit) transforms the existing context through the builder, seeded from the current value. Reach for withContext to set a context, editContext to enrich one.


Injecting and testing generated mappings

A concrete or threaded Impl is a stateless pure function reached through statics (INSTANCE, instance()); an element-mapped Impl is an immutable value built by of(...), carrying its element prisms. The spec interface deliberately declares nothing either way (@Autowired UserMapping injects nothing useful, by design). When you do want a Spring bean or a test double, register the surface you consume, per tier:

Tier surfaceInjectable shapeFrom
parse-capable mappingValidatedPrism<UserDto, User>UserMappingImpl.INSTANCE.asValidatedPrism()
build onlyFunction<User, UserDto>UserMappingImpl.INSTANCE::build
validated patchBiFunction<User, UserCardDto, Validated<NonEmptyList<FieldError>, User>>UserCardMappingImpl.INSTANCE::patch
sparse updateFromFunction<UserPatchDto, Edits.Accumulated<User>>UserPatchMappingImpl.INSTANCE::updateFrom
@Configuration
class MappingConfiguration {
  @Bean
  ValidatedPrism<UserDto, User> userCodec() {
    return UserMappingImpl.INSTANCE.asValidatedPrism();
  }
}

Spring resolves the full generic type, so codecs for different pairs coexist without ceremony; only two codecs for the same pair need a @Qualifier. An element-mapped Impl (of(...)) carries its prisms as state: construct it once, in the @Bean method.

Fakes are values, not mocks. ValidatedPrism is sealed, so it cannot be hand-implemented, and a mocking framework cannot mock it either (sealed types are unmockable). That is the design, not a limitation: a test double is two lines of ValidatedPrism.of(...):

@Bean
ValidatedPrism<UserDto, User> userCodec() {
  return ValidatedPrism.of(
      dto -> Validated.invalidNel(FieldError.of("rejected by the fake codec").at("email")),
      user -> new UserDto());
}

The hkj-spring example app demonstrates the seam end to end: MappingConfiguration registers the codec, UserController's parse endpoint injects it, and UserParseFakeCodecSliceTest substitutes the fake above in a @WebMvcTest slice and asserts the located 422 it produces. The same controller's PATCH endpoint deliberately calls UserPatchMappingImpl.INSTANCE directly: injection buys substitution, not lifecycle, and a team comfortable calling the Impl directly (INSTANCE, instance(), or one shared of(...) instance) loses nothing.


Diagnostics and limits

Every rejection follows the processor's what/why/fix standard: the message states what is wrong, why the mapper needs it, and the code to write. Current limits, each with its own diagnostic:

  • parse is assembled with Validated.fields(), which locates up to 16 components; group larger records into nested records, which nest through their own specs.
  • Nested and sealed resolution sees specs in the same compilation. A spec extends MappingSpec directly, plus any plain mix-in interfaces; a mix-in that is itself a mapping spec, or a generic one, is diagnosed.
  • Map components lift values only: keys are identity, so differing key types, raw Maps and wildcard type arguments are compile errors.
  • A projection with any fallible correspondence emits the validated patch write-back rather than asLens(); projections cannot carry derived fields (the write-back could never honour a recomputed component); generic records map as concrete instantiations, threaded specs or element-mapped specs (above), all three nestable; generic mappings stay record-to-record only.

See Also

  • Validated Prisms - The leaf optic every fallible correspondence is built from
  • Accumulating Assembly - The fields() builder behind the generated parse
  • Multi-Edit and Sparse Updates - The update-side counterpart at the same boundary
  • GenerateMappingExample in hkj-examples - every feature on this page, runnable; its GenerateMappingExampleLawsTest law-checks the example's mappings through MappingLaws

Previous: Focus DSL with External Libraries Next: Kind Field Support