Check Your Understanding

Ten questions on the Quickstart through the Capstone, each answer proved by the build.

These questions cover the pages from the Quickstart to the Capstone. They start with recall and end with writing specs of your own, and they do not follow page order, on purpose. Answer each question before you open its answer, in your head or on paper. Each answer ends with a link to the section that teaches it, and Where to go next turns your score into a plan.

Checkpoint 1: which direction can fail?

PersonMappingImpl has two methods, build and parse. Which of them can fail, and what does it hand back when it does?

Answer and why

parse. It returns a Validated<NonEmptyList<FieldError>, Person>: the domain value, or every bad field at once, each located by its path. build is total, so it returns the wire directly. The types say so:

    Person person = new Person("Ada", 36);
    PersonMappingImpl personMapping = PersonMappingImpl.INSTANCE; // bind once, reuse

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

Where this lives: Your first mapping.

Checkpoint 2: the same leaf in every spec

Every spec whose records carry an email repeats the same default ValidatedPrism<String, EmailAddress> email() method. Where do you declare that method once, so that every spec inherits it? And what does it do in a spec whose records have no email?

Answer and why

In a mix-in: a plain interface holding the leaf, which each spec extends alongside MappingSpec. An inherited leaf counts as if the spec declared it wherever a component matches it, and stays inert where none does, so TagMapping is accepted too:

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

record Lead(String name, EmailAddress email) {}

record LeadDto(String name, String email) {}

record Tag(String label) {}

record TagDto(String label) {}

@GenerateMapping
interface LeadMapping extends EmailVocabulary, MappingSpec<Lead, LeadDto> {}

@GenerateMapping
interface TagMapping extends EmailVocabulary, MappingSpec<Tag, TagDto> {}

Where this lives: Shared vocabulary: mix-in interfaces.

Checkpoint 3: an Optional against a plain String

Does @GenerateMapping accept this pair as written? Say what happens to a null phone, or what the processor asks you to add, and why.

record Guest(String name, Optional<String> phone) {}

record GuestDto(String name, String phone) {}

@GenerateMapping
interface GuestMapping extends MappingSpec<Guest, GuestDto> {}

Answer and why

Refused. Of the two fixes the message offers, the bridge comes first:

Add '@OptionalBridge java.util.Optional<java.lang.String> phone();' to the spec

The processor will not guess that a null means absent: on most record wires a null really is a defect, reported as a located must not be null. So the bridge is declared one component at a time, in writing. The other fix, a leaf over the whole Optional, maps the pair but leaves null a located error, so only the bridge gives the field an absent state.

Where this lives: Optional fields: @OptionalBridge.

Checkpoint 4: write the leaves

The processor refuses this spec. Write what it needs, using StandardCodecs.

enum Priority { LOW, HIGH }

record Ticket(UUID id, Priority priority) {}

record TicketDto(String id, String priority) {}

@GenerateMapping
interface TicketMapping extends MappingSpec<Ticket, TicketDto> {}

Answer and why

Two leaves, one for each component whose type differs on the wire. The processor never applies a codec on its own, so each conversion is declared. Each leaf is named after the domain component it parses, and its type arguments put the wire type first:

enum Priority { LOW, HIGH }

record Ticket(UUID id, Priority priority) {}

record TicketDto(String id, String priority) {}

@GenerateMapping
interface TicketMapping extends MappingSpec<Ticket, TicketDto> {
  default ValidatedPrism<String, UUID> id() {
    return StandardCodecs.uuid();
  }

  default ValidatedPrism<String, Priority> priority() {
    return StandardCodecs.enumByName(Priority.class);
  }
}

The refusal names the component and the leaf to add:

target field 'TicketDto.id' has no usable source. The types differ (java.lang.String vs
java.util.UUID) and no matching leaf method was found. Found on Ticket: [id, priority]. Add
'default ValidatedPrism<java.lang.String, java.util.UUID> id()' to the spec.

Where this lives: Standard codecs and Validated leaves.

Checkpoint 5: predict the result

InvoiceMapping nests CustomerMapping, whose email converts through a leaf. What does InvoiceMappingImpl.INSTANCE.parse(new InvoiceDto("INV-2", new CustomerDto(null, "not-an-email"))) return?

  1. It throws a NullPointerException
  2. Invalid, with customer.name: must not be null only
  3. Invalid, with name: must not be null and email: not an email address
  4. Invalid, with customer.name: must not be null and customer.email: not an email address

Answer and why

4. Every value parse reads is null-guarded, and a null becomes a located error beside every other bad field, never an exception. A nested spec's errors locate under the component that holds it:

    assertThatValidated(
            InvoiceMappingImpl.INSTANCE.parse(
                new InvoiceDto("INV-2", new CustomerDto(null, "not-an-email"))))
        .isInvalid()
        .hasFieldErrors("customer.name: must not be null", "customer.email: not an email address");

Where this lives: Null has an address, not a stack trace and Nesting, containers, and recursion.

Checkpoint 6: find the defect

The processor refuses this spec for Customer(String name, EmailAddress email) and CustomerDto(String name, String email). What is wrong, and how do you fix it?

@GenerateMapping
interface CustomerMapping extends MappingSpec<Customer, CustomerDto> {
  default ValidatedPrism<String, EmailAddress> emailAddress() {
    return EmailCodecs.EMAIL;
  }
}

Answer and why

The leaf is named after the type it produces, not the component it parses. A leaf is found by its name, so emailAddress() matches nothing, and the message says what it expects:

leaf 'emailAddress' names no component of Customer. A leaf is a zero-parameter 'default' named
after the DOMAIN component it parses (or an inner component of a flattened one); an unmatched
leaf would silently validate nothing.

Rename it email(). A leaf declared on the spec must bind, because one that matches nothing would validate nothing, silently. A leaf inherited from a mix-in, as in Checkpoint 2, may stay inert instead.

Where this lives: Validated leaves and Shared vocabulary: mix-in interfaces.

Checkpoint 7: predict the errors

Stay's constructor refuses a check-out that is not after its check-in, and StayMapping parses both dates with StandardCodecs.localDate(). A ReservationDto arrives with no guest. Its second stay checks in on 2026-03-09 and checks out on 07/03/2026, meaning 7 March. Which errors does ReservationMappingImpl.INSTANCE.parse report?

  1. guest: must not be null only
  2. guest, and stays.1: checkOut must be after checkIn
  3. guest, and stays.1.checkOut: not an ISO-8601 date (expected e.g. 2026-07-28)
  4. All three

Answer and why

3. The constructor needs every component, so it runs only once they have all parsed. checkOut never parsed, so the invariant is never checked, while the missing guest still accumulates beside it:

    ReservationDto request =
        new ReservationDto(
            null, // no guest
            List.of(
                new StayDto("2026-03-01", "2026-03-04"),
                new StayDto("2026-03-09", "07/03/2026"))); // meant to leave before it arrives

    assertThatValidated(ReservationMappingImpl.INSTANCE.parse(request))
        .isInvalid()
        .hasFieldErrors(
            "guest: must not be null",
            "stays.1.checkOut: not an ISO-8601 date (expected e.g. 2026-07-28)");

A record reports its components' errors or its invariant, never both.

Where this lives: A record's own invariants.

Checkpoint 8: does the sealed dispatch compile?

Each domain subtype has a spec. Does PaymentMapping compile? If it does, what does its parse do with each wire subtype? If it does not, what does the processor ask for?

sealed interface Payment permits Card, Bank {}

record Card(String pan) implements Payment {}

record Bank(String iban) implements Payment {}

sealed interface PaymentDto permits CardDto, BankDto, CashDto {}

record CardDto(String pan) implements PaymentDto {}

record BankDto(String iban) implements PaymentDto {}

record CashDto(String currency) implements PaymentDto {}

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

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

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

Answer and why

It does not compile. Sealed dispatch is exhaustive in both directions, so parse needs a domain subtype for every wire subtype. Nothing produces CashDto, so a parse that compiled would have nowhere to send one:

permitted subtype 'com.example.CashDto' of 'PaymentDto' is never produced. parse must dispatch
every wire subtype back to a domain subtype; this one has no mapping spec from any. Add a domain
subtype and spec for it, or remove it from the sealed wire interface.

Where this lives: Sealed hierarchies.

Checkpoint 9: would you approve it?

A teammate used to MapStruct binds the mapper on the spec itself, so every caller can write VisitorMapping.MAPPER:

record Visitor(String name, EmailAddress email) {}

record VisitorDto(String name, String email) {}

@GenerateMapping
interface VisitorMapping extends MappingSpec<Visitor, VisitorDto> {
  VisitorMappingImpl MAPPER = VisitorMappingImpl.INSTANCE;

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

It compiles, and the teammate's test, which calls VisitorMapping.MAPPER.parse(...), passes. Would you approve it? If not, say what fails, and when.

Answer and why

No. The Impl implements the spec, and the spec declares an instance method with a body (its email leaf). So initialising the Impl initialises the spec first. A program that reads VisitorMapping.MAPPER first is fine, which is why the test passes. A program that uses VisitorMappingImpl.INSTANCE first evaluates the constant while the Impl's INSTANCE is still null, and the constant keeps that null for good:

    // Two programs, each loading this package afresh, so neither sees what the other ran:
    assertThat(mapperAfterFirstUsing("VisitorMapping")).isNotNull(); // the spec first
    assertThat(mapperAfterFirstUsing("VisitorMappingImpl")).isNull(); // the Impl first

Which class a program reaches first depends on its code paths, so the failure comes and goes. Bind the Impl in the calling code instead: a local, a field of the calling class, or an injected ValidatedPrism.

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

Checkpoint 10: map the shipment

Write what maps this pair both ways. A bad parcel must be reported by its position in the list, and a null note must read as an absent one. Then say what path the client reads when the second parcel has no sku.

record Parcel(String sku, int grams) {}

record ParcelDto(String sku, int grams) {}

record Shipment(UUID id, List<Parcel> parcels, Optional<String> note) {}

record ShipmentDto(String id, List<ParcelDto> items, @Nullable String note) {}

Answer and why

Two specs: one for the parcel pair, which lifts the list element by element, and one for the shipment. The shipment spec needs a StandardCodecs leaf for id, a rename from parcels to the wire's items, and a bridge for note. sku and grams match by name and type, so they need nothing:

@GenerateMapping
interface ParcelMapping extends MappingSpec<Parcel, ParcelDto> {}

@GenerateMapping
interface ShipmentMapping extends MappingSpec<Shipment, ShipmentDto> {
  default ValidatedPrism<String, UUID> id() {
    return StandardCodecs.uuid();
  }

  @MapField(to = "items")
  List<Parcel> parcels();

  @OptionalBridge
  Optional<String> note();
}

The client reads parcels.1.sku: error paths use the domain's names, even where the wire says items. The test checks all three requirements:

    ShipmentMappingImpl shipments = ShipmentMappingImpl.INSTANCE;

    assertThatValidated(
            shipments.parse(
                new ShipmentDto(
                    "not-a-uuid",
                    List.of(new ParcelDto("A-1", 250), new ParcelDto(null, 90)),
                    null)))
        .isInvalid()
        .hasFieldErrors(
            "id: not a UUID (expected e.g. 123e4567-e89b-12d3-a456-426614174000)",
            "parcels.1.sku: must not be null"); // the domain's name, not the wire's `items`

    UUID id = UUID.fromString("123e4567-e89b-12d3-a456-426614174000");
    assertThatValidated(
            shipments.parse(
                new ShipmentDto(id.toString(), List.of(new ParcelDto("A-1", 250)), null)))
        .hasValue(new Shipment(id, List.of(new Parcel("A-1", 250)), Optional.empty()));

Where this lives: Nesting, containers, and recursion, Renames: @MapField, Standard codecs and Optional fields: @OptionalBridge.

See Example Code

The code on this page is SelfCheckBook.java and SelfCheckBookTest.java: the page includes both directly, and the test asserts every result an answer predicts. The build also compiles every spec a question or answer shows, and checks each quoted refusal against the processor's own words. Open them after the ten, since they hold the answers.


Where to go next

A question counts when every part of your answer was right before you opened it, and a spec you wrote counts when it declares the same members as the answer's. Whatever your score, open the Where this lives link of any answer you missed.

Your scoreNext step
8 to 10, with Checkpoints 4 and 10 among themYou can ship a boundary. Read the rest of the chapter as a task calls for it: What Your Spec Generates for what a spec gets, and Compiler Messages when the processor refuses one
Any other score from 5 to 9Reread the sections you missed, try those questions again, then carry on as for 8 to 10
4 or fewerGo back to Record Mapping Basics and read each page through to the Capstone, working the Boundary Mapping Journey alongside, then take the questions again

Previous: Capstone: One 422, Every Bad Field Next: What Your Spec Generates