Record Mapping Basics

Declare one interface; get a build that cannot fail and a parse that reports every bad field at once.

Most mappings are boring, and the mapper treats them that way: same-named, same-typed components match automatically, and one empty interface is the whole declaration. This page walks the happy path first: declare, build, parse, read the errors, convert a field, and meet a null. Renames and computed fields follow, for when you need them. The mapper needs Java 25 and the hkj Gradle plugin, and the Quickstart sets both up. The precise rules live on a page of their own, Rules and Limits.

What You'll Learn

  • Declare a mapping as a MappingSpec<Domain, Wire> interface, and keep its generated Impl in the calling code, never on the spec
  • Use a parse result: the value, or every bad field at once, each located by its path
  • Convert a field whose type differs with a ValidatedPrism leaf
  • Predict what parse does with a null

See Example Code

The code on this page is BasicsBook.java and its BasicsBookTest.java - the page includes them directly, so they are compiled and run by the build.

For a larger worked example, see GenerateMappingExample.java.

Your first mapping

The whole declaration is an empty interface naming the pair, domain first and wire second. The wire is the DTO: the shape that crosses the network. Most examples here map the records of an order service, starting with its Address.

record Address(String street, String city, String postcode) {}

record AddressDto(String street, String city, String postcode) {}

@GenerateMapping
interface AddressMapping extends MappingSpec<Address, AddressDto> {}


    Address address = new Address("1 High Street", "Leeds", "LS1 4AP");
    AddressMappingImpl addressMapping = AddressMappingImpl.INSTANCE; // bind once, reuse

    // Same-named, same-typed components match automatically:
    AddressDto dto = addressMapping.build(address); // total
    Validated<NonEmptyList<FieldError>, Address> back =
        addressMapping.parse(dto); // accumulating, located

That is all of it: no mapper class, no configuration. The processor derives both directions from the two records at compile time, without reflection. It re-derives them on every compile, so the mapping cannot drift away from the records it maps. Add a wire component that nothing fills, and the build stops: has more components than.

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>

A Validated holds either the parsed value (Valid) or every error (Invalid), and a NonEmptyList is a list with at least one element.

The processor generates AddressMappingImpl in the spec's package, and you reach it through its INSTANCE constant. A spec nested in an outer class joins the enclosing simple names: Shop.CustomerMapping generates ShopCustomerMappingImpl. A generic spec is reached through instance() or of(...) instead.

Bind in the caller, not on the spec

Code that calls a mapping more than once keeps the Impl in a variable, as addressMapping does, and reuses it. Reading INSTANCE costs nothing, so the variable is only for readability. Type it as the Impl, since build and parse live there, not on the spec. A local suits a few calls in one method, and a private static final field suits a class that maps in several methods. In Spring, register the mapping as a ValidatedPrism bean and inject that, as the example app does:

@Configuration
public class MappingConfiguration {

  /**
   * The user wire codec: parse a {@link UserDto} into the domain, or render a {@link User} back.
   *
   * @return the generated mapping's {@link ValidatedPrism} surface
   */
  @Bean
  public ValidatedPrism<UserDto, User> userCodec() {
    return UserMappingImpl.INSTANCE.asValidatedPrism();
  }
}

Injecting and testing generated mappings lists the surface to register for each kind of mapping.

Not checked for you: never bind the Impl on the spec

MapStruct's idiom puts the mapper's instance on its own interface. Here that would be CustomerMappingImpl MAPPER = CustomerMappingImpl.INSTANCE;, declared on CustomerMapping, the spec with an email leaf in Validated leaves. It compiles, and it can read null. If any code uses CustomerMappingImpl.INSTANCE before the first read of CustomerMapping.MAPPER, the constant stays null for good, and the next MAPPER.parse(...) throws a NullPointerException. Which class a program reaches first depends on its code paths, so the failure comes and goes. A spec with no leaf or derived field is safe, so the constant can work for months and break the day someone adds one. Keep the Impl in the calling code instead.

Why the constant reads null

The Impl implements the spec. Initialising a class first initialises every interface it implements that declares an instance method with a body: every default leaf and derived field, and any private instance helper. Using CustomerMappingImpl.INSTANCE first starts the Impl's initialisation, which initialises CustomerMapping before INSTANCE is assigned. MAPPER is evaluated then, and keeps the null it read. Two threads making those first uses at the same moment can deadlock instead. A constant on a mix-in that declares a leaf fails the same way. The processor sees a field's type but not its initialiser, so it cannot tell this constant from a harmless one, and does not refuse it.


Validated leaves

Real boundaries convert: the wire sends a String, and the domain wants an email that has already been checked. A leaf is the conversion at a single field, where the mapping stops copying and one wire value becomes one domain value. Think of it as a Jackson serialiser and deserialiser for one field, except that a bad value comes back as an error naming the field, not an exception. The leaf itself is a ValidatedPrism, two functions: a parse that may reject, and a render back to the wire that cannot fail:

record EmailAddress(String value) {}

final class EmailCodecs {
  static final ValidatedPrism<String, EmailAddress> EMAIL =
      ValidatedPrism.of(
          raw ->
              raw.contains("@")
                  ? Validated.validNel(new EmailAddress(raw))
                  : Validated.invalidNel(FieldError.of("not an email address")),
          EmailAddress::value);

  private EmailCodecs() {}
}

validNel and invalidNel build the two outcomes. The leaf's error carries only a message, and parse adds the field's path.

Attach it to the spec 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;
  }
}


    Validated<NonEmptyList<FieldError>, Customer> parsed =
        CustomerMappingImpl.INSTANCE.parse(new CustomerDto("Bob", "not-an-email"));
    // Invalid(NonEmptyList[email: not an email address])

The Impl reads the leaf once, on first use, and keeps what it answers, as Your own canon explains.

The two type-argument orders are opposite. ValidatedPrism<Wire, Domain> reads the way parse runs, from wire to domain. MappingSpec<Domain, Wire> puts your domain type first.

The failure is a value, not an exception. It names the field by the domain component's name, whatever key the JSON used, as Renames explains. With several bad fields, parse reports them all at once, so the client fixes everything in one round trip. Outside a controller, fold the result, with one function for the errors and one for the value. Valid and Invalid are records, so a switch works too:

    // One function for every error, one for the value:
    List<String> report =
        parsed.fold(
            errors -> errors.map(e -> e.pathString() + " -> " + e.message()).toJavaList(),
            customer -> List.of());
    // [email -> not an email address]

At the Spring boundary

Returned as-is from a controller, the result becomes the single 422 response the Quickstart showed: the 422 leg.

Not checked for you: keep a converted wire field a String

Jackson binds the request before parse runs. Type a wire field that a leaf converts as a String. Typed as a LocalDate or an enum, a bad value fails inside Jackson, and the client gets Jackson's 400 instead of a located error. The Quickstart's Read the 422 step shows the difference.

Most leaves come ready-made: Standard Codecs, the next page, covers identifiers, dates, enums and money. Coming from Bean Validation? From Bean Validation translates the common annotations.

A leaf can check a field that needs no conversion

An explicit leaf wins even when both types are identical, so a ValidatedPrism<String, String> can validate a field that would otherwise be copied. Check, but do not clean up: a parse that trims " ada@example.com" accepts a spelling build never writes back. That breaks the section law: an accepted wire value must rebuild to exactly itself. Reject the spaces instead.


Null has an address, not a stack trace

Jackson leaves a missing property null, so a boundary meets nulls constantly. The rule: parse checks every value it reads from the wire, and a null becomes a must not be null error at that field's path, reported beside every other bad field, never thrown.

    Validated<NonEmptyList<FieldError>, Customer> missing =
        CustomerMappingImpl.INSTANCE.parse(new CustomerDto(null, "not-an-email"));
    // Invalid(NonEmptyList[name: must not be null, email: not an email address])

The path reaches into nested records (customer.name) and lists (lines.1, the second line item). A null never reaches a leaf, so a leaf needs no null check of its own.

A primitive wire component, such as an int quantity, never holds a null, so the rule does not reach it: what a missing or malformed one becomes is Jackson's decision.

Why this matters

Compare the alternatives you have debugged before: an NPE with a stack trace pointing into generated code, or Jackson's MismatchedInputException naming a Java class. A located error names the field by its path, sits beside every other defect in the same response, and costs the client one round trip instead of one per null.

The exact contract, including what happens inside containers, is in The null contract, precisely. A null DTO passed to parse still throws, since that one is the caller's bug. A field whose null means something can opt out of this rule, one field at a time, as Absent Fields and Record Invariants shows.

You can ship now

You can now map a record DTO to a domain record and back, check each field as it arrives, and answer with every bad field at once. Every field is required: to let one be left out, see Absent Fields. The rest of this page, renames and computed wire fields, is for when a pair needs them.

Checkpoint: which MAPPER can read null?

Both of these specs carry the MapStruct-style constant. In each program, some code uses the Impl's INSTANCE before anything reads the spec's MAPPER. Afterwards, which MAPPER can read null?

// The MapStruct idiom, on two specs: never do this.
record Warehouse(String code, int bays) {}

record WarehouseDto(String code, int bays) {}

@GenerateMapping
interface WarehouseMapping extends MappingSpec<Warehouse, WarehouseDto> {
  WarehouseMappingImpl MAPPER = WarehouseMappingImpl.INSTANCE;
}

record Courier(String name, EmailAddress email) {}

record CourierDto(String name, String email) {}

@GenerateMapping
interface CourierMapping extends MappingSpec<Courier, CourierDto> {
  CourierMappingImpl MAPPER = CourierMappingImpl.INSTANCE;

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

  1. Neither: an interface constant is set before any code can use it
  2. WarehouseMapping.MAPPER only
  3. CourierMapping.MAPPER only
  4. Both

Answer and why

3. CourierMapping declares a leaf, so using CourierMappingImpl.INSTANCE first leaves its MAPPER null for good. WarehouseMapping has no leaf, so its constant is safe, for now:

    // Two programs, each loading this package afresh and using the Impl before MAPPER:
    assertThat(mapperAfterFirstUsing("WarehouseMappingImpl", "WarehouseMapping")) // no leaf
        .isNotNull();
    assertThat(mapperAfterFirstUsing("CourierMappingImpl", "CourierMapping")) // a leaf
        .isNull();

The day someone adds a leaf to WarehouseMapping, its constant breaks too. Delete both constants, and keep each Impl in the calling code.

Where this lives: Bind in the caller, not on the spec.

Checkpoint: does a null reach the leaf?

CustomerMapping converts email through the email leaf, which calls raw.contains("@"). What does CustomerMappingImpl.INSTANCE.parse(new CustomerDto(null, null)) return?

  1. It throws a NullPointerException from the leaf's raw.contains("@")
  2. Invalid, with name: must not be null only
  3. Invalid, with name: must not be null and email: must not be null
  4. Invalid, with name: must not be null and email: not an email address

Answer and why

3. Each null becomes a located error, and neither stops the other. The null check runs before the leaf, so the leaf never sees the null email:

    assertThatValidated(CustomerMappingImpl.INSTANCE.parse(new CustomerDto(null, null)))
        .hasFieldErrors("name: must not be null", "email: must not be null");

Where this lives: Null has an address, not a stack trace.


Renames: @MapField

When the wire calls it fullName and the domain calls it name, as a partner's customer feed might, declare an abstract method named after the domain component, with to naming the wire component:

record PartnerCustomerDto(String fullName, String email) {} // a partner's customer feed

@GenerateMapping
interface PartnerCustomerMapping extends MappingSpec<Customer, PartnerCustomerDto> {
  @MapField(to = "fullName")
  String name(); // Customer.name <-> PartnerCustomerDto.fullName

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

Each wire component takes exactly one domain source, so the processor refuses two renames onto one name. Give the method the domain component's type, as String name() does. The Impl only names the method, and never calls it.

The email leaf comes along because this spec maps the whole Customer, and each spec declares the leaves its own pair needs. A mix-in declares them once for several specs. The Capstone's CustomerDto makes the same rename.

Error paths use domain names, not JSON keys

A client that sent fullName gets its errors at name. The same holds for a Jackson rename: under @JsonProperty("first_name") on a firstName component, or a snake_case naming strategy, the path stays firstName. Every path in the system is domain-named, so paths stay consistent, and stable when the wire is refactored. A client that maps errors back onto its own payload keys applies the renames in reverse.

Renamed and converted

A mailing list's export calls the email emailAddress, and its string still has to be checked into an EmailAddress. The leaf is already the component's one method named email(), and a rename marker beside it would be a second email(), which Java refuses. So @MapField goes on the leaf, and means both:

record MailingListCustomerDto(String name, String emailAddress) {} // a mailing list's export

@GenerateMapping
interface MailingListCustomerMapping extends MappingSpec<Customer, MailingListCustomerDto> {
  @MapField(to = "emailAddress") // Customer.email <-> MailingListCustomerDto.emailAddress
  default ValidatedPrism<String, EmailAddress> email() { // renamed, and converted
    return EmailCodecs.EMAIL;
  }
}

The error still names the domain component:

    Validated<NonEmptyList<FieldError>, Customer> subscribed =
        MailingListCustomerMappingImpl.INSTANCE.parse(
            new MailingListCustomerDto("Ada", "not-an-email"));
    // Invalid(NonEmptyList[email: not an email address])

Derived wire fields

A wire component with no domain counterpart can be computed from the whole domain value: a displayName the domain does not store, because it is derivable. Declare a zero-parameter default method named after the wire component, returning Getter<Domain, WireComponentType>. Read the Getter as a Function<Domain, WireComponentType>: Getter.of takes a plain lambda.

record Recipient(String first, String last) {}

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

@GenerateMapping
interface RecipientMapping extends MappingSpec<Recipient, RecipientDto> {
  default Getter<Recipient, String> displayName() {
    return Getter.of(r -> r.first() + " " + r.last());
  }
}


    RecipientDto built = RecipientMappingImpl.INSTANCE.build(new Recipient("Ada", "Lovelace"));
    // RecipientDto[first=Ada, last=Lovelace, displayName=Ada Lovelace]

The two directions are asymmetric: build computes the derived component, and parse throws it away, since the data is derivable and nothing is lost. A client that sends displayName has it ignored, not checked.

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

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

Spec members says how the processor tells a leaf from a derived field, and What Your Spec Generates what a derived field changes in the generated methods.


Key Takeaways

  • A mapping is an interface you own: @GenerateMapping on a MappingSpec<Domain, Wire> generates <Spec>Impl with build and parse, which the calling code keeps, never a constant on the spec
  • Two directions, two shapes: build is total, and parse returns the value or every bad field at once, each located by a domain-named path
  • Leaves convert, renames rename, getters derive: ValidatedPrism leaves for fields whose types differ, @MapField for names, Getter defaults for wire-only fields
  • Null is located, never thrown: a null becomes must not be null at its path, inside containers too, and never reaches a leaf

See Also


Previous: Quickstart: Your First 422 Next: Standard Codecs and Shared Vocabulary