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.
AddressMappingImpl has two methods, build and parse. Which of them can fail, and what does it hand back when it does?
Answer and why
Answer and why
parse. It returns a Validated<NonEmptyList<FieldError>, Address>: 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:
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
Where this lives: Your first mapping.
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
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 WarehouseMapping is accepted too:
interface EmailVocabulary {
default ValidatedPrism<String, EmailAddress> email() {
return EmailCodecs.EMAIL;
}
}
record Courier(String name, EmailAddress email) {} // as on Record Mapping Basics
record CourierDto(String name, String email) {}
record Warehouse(String code, int bays) {} // as on Record Mapping Basics
record WarehouseDto(String code, int bays) {}
@GenerateMapping
interface CourierMapping extends EmailVocabulary, MappingSpec<Courier, CourierDto> {}
@GenerateMapping
interface WarehouseMapping extends EmailVocabulary, MappingSpec<Warehouse, WarehouseDto> {}
Where this lives: Shared vocabulary: mix-in interfaces.
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 Driver(String name, Optional<String> phone) {} // drives for a courier firm
record DriverDto(String name, String phone) {}
@GenerateMapping
interface DriverMapping extends MappingSpec<Driver, DriverDto> {}
Answer and why
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.
The processor refuses this spec. Write what it needs, using StandardCodecs.
enum Priority { LOW, HIGH }
record Ticket(UUID id, Priority priority) {} // a support desk ticket
record TicketDto(String id, String priority) {}
@GenerateMapping
interface TicketMapping extends MappingSpec<Ticket, TicketDto> {}
Answer and why
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.
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?
- It throws a
NullPointerException - Invalid, with
customer.name: must not be nullonly - Invalid, with
name: must not be nullandemail: not an email address - Invalid, with
customer.name: must not be nullandcustomer.email: not an email address
Answer and why
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 a spec, and a list of them.
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
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.
DeliveryWindow's constructor refuses a window whose latest date is before its earliest, and DeliveryWindowMapping parses both dates with StandardCodecs.localDate(). A DeliveryDto arrives with no order id. Its second window's earliest is 2026-03-09 and its latest is 07/03/2026, meaning 7 March. Which errors does DeliveryMappingImpl.INSTANCE.parse report?
orderId: must not be nullonlyorderId, andwindows.1: latest must not be before earliestorderId, andwindows.1.latest: not an ISO-8601 date (expected e.g. 2026-07-28)- All three
Answer and why
Answer and why
3. The constructor needs every component, so it runs only once they have all parsed. latest never parsed, so the invariant is never checked, while the missing order id still accumulates beside it:
DeliveryDto request =
new DeliveryDto(
null, // no order id
List.of(
new DeliveryWindowDto("2026-03-01", "2026-03-04"),
new DeliveryWindowDto("2026-03-09", "07/03/2026"))); // 7 March, before earliest
assertThatValidated(DeliveryMappingImpl.INSTANCE.parse(request))
.isInvalid()
.hasFieldErrors(
"orderId: must not be null",
"windows.1.latest: 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.
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
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.
A teammate used to MapStruct binds the mapper on the spec itself, so every caller can write CustomerSummaryMapping.MAPPER:
// The chapter's Customer, on the wire an order summary shows it on.
record CustomerSummaryDto(String name, String email) {}
@GenerateMapping
interface CustomerSummaryMapping extends MappingSpec<Customer, CustomerSummaryDto> {
@SuppressWarnings("impl-constant") // kept to show the trap
CustomerSummaryMappingImpl MAPPER = CustomerSummaryMappingImpl.INSTANCE;
default ValidatedPrism<String, EmailAddress> email() {
return EmailCodecs.EMAIL;
}
}
The processor warned at the constant, so the teammate suppressed the warning. It compiles, and the teammate's test, which calls CustomerSummaryMapping.MAPPER.parse(...), passes. Would you approve it? If not, say what fails, and when.
Answer and why
Answer and why
No. The warning was right. 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 CustomerSummaryMapping.MAPPER first is fine, which is why the test passes. A program that uses CustomerSummaryMappingImpl.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("CustomerSummaryMapping")).isNotNull(); // the spec first
assertThat(mapperAfterFirstUsing("CustomerSummaryMappingImpl")).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: A spec never holds its Impl in a constant.
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
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 a spec, and a list of them, Renames: @MapField, Standard codecs and Optional fields: @OptionalBridge.
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 score | Next step |
|---|---|
| 8 to 10, with Checkpoints 4 and 10 among them | You 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 9 | Reread the sections you missed, try those questions again, then carry on as for 8 to 10 |
| 4 or fewer | Go 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