Nesting, Containers, and Sealed Hierarchies

A record that holds records, lists of them, or a sealed type maps through the specs you already write, and every failure names its full path.

Real DTOs are not flat: an order carries a customer and a list of lines, and a payment is a card or a bank transfer. This page walks those three first, a nested spec, a list of them and a sealed dispatch, all through machinery you already have. Optional objects, other containers, map keys, flattening and specs from other modules follow, for when a pair needs them.

What You'll Learn

  • Nest a spec in another, in a List, or behind a sealed dispatch, and predict the path each failure reports
  • Predict what happens to a mapped List after parse or build hands it over

See Example Code

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

Nesting a spec, and a list of them

When a component's two types are a pair another spec maps, the outer spec uses that spec. CustomerMapping, from Validated leaves, maps Customer and CustomerDto, so InvoiceMapping stays empty, and a failure inside comes back under the component's name. Forget the inner spec, and the processor refuses the outer one with has no usable source, offering a spec.

record Invoice(String id, Customer customer) {}

record InvoiceDto(String id, CustomerDto customer) {}

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


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

A List of such pairs lifts the element's spec, which means it parses every element through it. Think dtos.stream().map(mapper::toDomain).toList(), except that a bad element does not stop the others, and each failure keeps its index. Here the second line's price fails its leaf:

record LineItem(String sku, BigDecimal price) {}

record LineItemDto(String sku, String price) {}

record Cart(String id, List<LineItem> lines) {}

record CartDto(String id, List<LineItemDto> lines) {}

@GenerateMapping
interface LineItemMapping extends MappingSpec<LineItem, LineItemDto> {
  default ValidatedPrism<String, BigDecimal> price() {
    return StandardCodecs.bigDecimal();
  }
}

@GenerateMapping
interface CartMapping extends MappingSpec<Cart, CartDto> {} // lines: LineItemMapping, per element


    Validated<NonEmptyList<FieldError>, Cart> cart =
        CartMappingImpl.INSTANCE.parse(
            new CartDto(
                "C-1",
                List.of(new LineItemDto("SKU-1", "9.99"), new LineItemDto("SKU-2", "12,50"))));
    // Invalid(NonEmptyList[lines.1.price: not a number in plain notation (expected e.g. 123.45)])

That is the lines.1.price error from the chapter's opening response:

%%{init: {"sequence": {"actorMargin": 20, "diagramMarginX": 10}}}%%
sequenceDiagram
    accTitle: How an error path grows outward
    accDescr: The price leaf reports a bare message. LineItemMapping puts price in front of it, the list puts the index 1 in front of that, and CartMapping puts lines in front, so the client reads lines.1.price.
    participant C as CartMapping
    participant L as lines list
    participant I as LineItemMapping
    participant P as price leaf
    C->>L: parse lines
    L->>I: parse index 1
    I->>P: parse "12,50"
    P-->>I: not a number...
    I-->>L: price: not a number...
    L-->>C: 1.price: not a number...
    Note over C: lines.1.price

In words: the leaf reports only its message, and on the way out each level puts its own name in front, first price, then the index 1, then lines.

Indexes count from zero, so lines.1 is the second line. An array lifts exactly as a List does, and a null element is reported at its index, under the null rule. Lifting needs the same container on both sides: What lifts, and what does not has the exact rule.

Not checked for you: a mapped List, Set or Map is unmodifiable

A component declared as a List, Set or Map comes out of parse and build unmodifiable, whether its elements went through a spec, like lines, or needed no conversion, like a List<String> tags. Adding to one throws UnsupportedOperationException, so copy it first. One that needs no conversion is copied both ways, so neither side holds the other's list. A component declared as ArrayList, or any other subtype, is shared as it is. Same-typed containers cross as copies has the precise rule.


Sealed hierarchies

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

sealed interface Payment permits Card, Bank {}

record Card(String pan) implements Payment {}

record Bank(String iban) implements Payment {}

// Jackson binds the wire before parse runs, so the wire says how to tell its subtypes apart.
@JsonTypeInfo(use = JsonTypeInfo.Id.DEDUCTION) // by their fields: pan or iban
@JsonSubTypes({@JsonSubTypes.Type(CardDto.class), @JsonSubTypes.Type(BankDto.class)})
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);
//   };

The dispatch hands each value to its subtype's spec and adds nothing to the path, since the JSON has no key for the subtype:

    PaymentMappingImpl paymentMapping = PaymentMappingImpl.INSTANCE;

    PaymentDto bankWire = paymentMapping.build(new Bank("GB33BUKB20201555555555"));
    // BankDto[iban=GB33BUKB20201555555555]
    Validated<NonEmptyList<FieldError>, Payment> card =
        paymentMapping.parse(new CardDto("4111111111111111"));
    // Valid(Card[pan=4111111111111111])

A leaf for pan goes on CardMapping, because a sealed spec has no components to bind one to (has no meaning on a sealed mapping). The dispatch cannot be partial. The processor names a missing subtype, with has no mapping spec on the domain side and is never produced on the wire. A subtype must be a record or a sealed interface, or on the wire a bean as well. A generic subtype, an enum or any other class is not supported yet.

Not checked for you: Jackson must be told how to tell the subtypes apart

Jackson binds the request before parse runs, and cannot construct an interface. Without type information, Jackson fails with a type-definition error, which Spring maps to no status by default. The exception escapes unhandled, and the server answers 500 with no field path. Annotate the wire interface as PaymentDto is: @JsonTypeInfo(use = JsonTypeInfo.Id.DEDUCTION) with @JsonSubTypes naming each record, when each subtype's fields differ. Where they overlap, use Id.NAME with a type property. An empty subtype changes what {} means:

sealed interface Fulfilment permits Shipped, Collected {}

record Shipped(String tracking) implements Fulfilment {}

record Collected() implements Fulfilment {} // collected in store: nothing to carry

@JsonTypeInfo(use = JsonTypeInfo.Id.DEDUCTION) // by their fields, and {} is CollectedDto
@JsonSubTypes({@JsonSubTypes.Type(ShippedDto.class), @JsonSubTypes.Type(CollectedDto.class)})
sealed interface FulfilmentDto permits ShippedDto, CollectedDto {}

record ShippedDto(String tracking) implements FulfilmentDto {}

record CollectedDto() implements FulfilmentDto {}

@GenerateMapping
interface ShippedMapping extends MappingSpec<Shipped, ShippedDto> {}

@GenerateMapping
interface CollectedMapping extends MappingSpec<Collected, CollectedDto> {}

@GenerateMapping
interface FulfilmentMapping extends MappingSpec<Fulfilment, FulfilmentDto> {}

DEDUCTION binds {} as CollectedDto, so a shipment missing its tracking parses as Collected, silently. Id.NAME avoids that.

You can ship now

You can now map records that hold other records, lists of them, and sealed hierarchies, with every failure located by its full path. A nested object the client may leave out takes @OptionalBridge. The rest of this page, other containers, map keys, flattening and specs from other modules, is for when a pair needs them.

Checkpoint: where does each failure locate?

CheckoutMapping maps a List of the sealed payments:

record Checkout(String id, List<Payment> payments) {}

record CheckoutDto(String id, List<PaymentDto> payments) {}

@GenerateMapping
interface CheckoutMapping extends MappingSpec<Checkout, CheckoutDto> {}

A client sends three payments: a card with no card number (pan), a valid bank transfer, and a bank transfer with no IBAN. What does CheckoutMappingImpl.INSTANCE.parse report?

  1. payments.0.pan: must not be null only, since the list stops at its first bad element
  2. payments.1.pan and payments.3.iban, each must not be null
  3. payments.0.Card.pan and payments.2.Bank.iban, each must not be null
  4. payments.0.pan and payments.2.iban, each must not be null

Answer and why

4. A bad element does not stop the others, and indexes count from zero, so the first and third payments are 0 and 2. The dispatch adds no segment, so each path goes straight from the index to the field:

    assertThatValidated(
            CheckoutMappingImpl.INSTANCE.parse(
                new CheckoutDto(
                    "C-1",
                    List.of(
                        new CardDto(null),
                        new BankDto("GB33BUKB20201555555555"),
                        new BankDto(null)))))
        .hasFieldErrors("payments.0.pan: must not be null", "payments.2.iban: must not be null");

Where this lives: Nesting a spec, and a list of them, Sealed hierarchies and Null has an address.

Checkpoint: whose list is it?

OrderNote and OrderNoteDto both hold a List<String> tags, and OrderNoteMapping is empty:

record OrderNote(String text, List<String> tags) {}

record OrderNoteDto(String text, List<String> tags) {}

@GenerateMapping
interface OrderNoteMapping extends MappingSpec<OrderNote, OrderNoteDto> {}

A controller parses an OrderNoteDto whose tags is the ArrayList Jackson bound, holding "vip". A later filter clears that ArrayList. What does the parsed note's tags() hold then, and what does note.tags().add("urgent") do?

  1. Nothing, and the add succeeds
  2. "vip", and the add succeeds
  3. "vip", and the add throws UnsupportedOperationException
  4. Nothing, and the add throws UnsupportedOperationException

Answer and why

3. tags is declared List and needs no conversion, so parse copies it, and the copy is unmodifiable. Clearing the request's list cannot reach the note, and adding to the note's list throws:

    List<String> requestTags = new ArrayList<>(List.of("vip")); // what Jackson bound
    OrderNote note =
        OrderNoteMappingImpl.INSTANCE.parse(new OrderNoteDto("Call back", requestTags)).get();
    requestTags.clear(); // a later filter clears the request's list

    assertThat(note.tags()).containsExactly("vip"); // a copy: still there
    assertThatThrownBy(() -> note.tags().add("urgent"))
        .isInstanceOf(UnsupportedOperationException.class); // and unmodifiable

Build a new OrderNote with the tags you want, or copy them first with new ArrayList<>(note.tags()).

Where this lives: Nesting a spec, and a list of them.


Optional nested objects

When a client leaves an object out, or sends null for it, the wire holds a nullable CustomerDto where the domain holds an Optional<Customer>. That is the @OptionalBridge shape, and when a spec maps the pair, the marker is all the component needs:

record Referral(String code, Optional<Customer> referrer) {}

// A client with no referrer leaves the object out, which the JSON binder reads as null.
record ReferralDto(String code, @Nullable CustomerDto referrer) {}

@GenerateMapping
interface ReferralMapping extends MappingSpec<Referral, ReferralDto> {
  // CustomerMapping maps the element pair, so the marker is all this component needs.
  @OptionalBridge
  Optional<Customer> referrer();
}


    ReferralMappingImpl referralMapping = ReferralMappingImpl.INSTANCE;

    Validated<NonEmptyList<FieldError>, Referral> noReferrer =
        referralMapping.parse(new ReferralDto("R-7", null));
    // Valid(Referral[code=R-7, referrer=Optional.empty])

    Validated<NonEmptyList<FieldError>, Referral> badReferrer =
        referralMapping.parse(new ReferralDto("R-7", new CustomerDto("Bob", "nope")));
    // Invalid(NonEmptyList[referrer.email: not an email address])

parse reads null as empty, and hands a present value to the nested spec, so its failures locate under the component. build writes the nested build of a present value, and null for an empty one.

A bridged list lifts the same way. Where an absent list and an empty one mean different things, the domain holds an Optional<List<Customer>>, and the marker is again all it needs:

// A promotion open to everyone comes with no invitee list, which is not an empty one: an empty
// list invites no one. So the domain keeps the difference.
record Promotion(String name, Optional<List<Customer>> invitees) {}

// A client that sends no list leaves the array out, which the JSON binder reads as null.
record PromotionDto(String name, @Nullable List<CustomerDto> invitees) {}

@GenerateMapping
interface PromotionMapping extends MappingSpec<Promotion, PromotionDto> {
  // CustomerMapping maps the elements, so the marker is all the list needs.
  @OptionalBridge
  Optional<List<Customer>> invitees();
}


    PromotionMappingImpl promotionMapping = PromotionMappingImpl.INSTANCE;

    Validated<NonEmptyList<FieldError>, Promotion> noInvitees =
        promotionMapping.parse(new PromotionDto("Spring launch", null));
    // Valid(Promotion[name=Spring launch, invitees=Optional.empty])

    Validated<NonEmptyList<FieldError>, Promotion> badInvitee =
        promotionMapping.parse(
            new PromotionDto(
                "Spring launch",
                List.of(
                    new CustomerDto("Ada", "ada@example.org"), new CustomerDto("Bob", "nope"))));
    // Invalid(NonEmptyList[invitees.1.email: not an email address])

An empty list parses to a present, empty Optional, and What converts a bridged value covers every bridged container. Where an empty list already says there are none, a plain List<Customer> is simpler.


Other containers, and recursion

A Set, an array, an Optional and a Map lift like a List. Each locates a failure by whatever identifies an element in that container:

ComponentLifts through the element's leaf or specA failure locates by
List<E>✅its index: lines.1.price through a nested spec
E[]✅its index, exactly as a list: standby.1
Set<E>✅the element's own rendering (agents.nope), since a set has no index
Optional<E> on both sides✅the component itself, since there is only one element
Map<K, V>✅ values, and keys with @MapKeythe source key: notes.bad-key
// The support desk: its agents, those on standby in turn, and a note per agent.
record SupportDesk(
    Set<EmailAddress> agents, // a Set lifts like a List
    EmailAddress[] standby, // so does an array
    Map<EmailAddress, String> notes) {} // and a Map's KEYS, with @MapKey

record SupportDeskDto(Set<String> agents, String[] standby, Map<String, String> notes) {}

@GenerateMapping
interface SupportDeskMapping extends MappingSpec<SupportDesk, SupportDeskDto> {
  default ValidatedPrism<String, EmailAddress> agents() {
    return EmailCodecs.EMAIL;
  }

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

  // A value leaf is named after its component; a key leaf is named BY its annotation,
  // because the two cannot share the one name Java allows.
  @MapKey("notes")
  default ValidatedPrism<String, EmailAddress> noteKey() {
    return EmailCodecs.EMAIL;
  }
}


    Validated<NonEmptyList<FieldError>, SupportDesk> desk =
        SupportDeskMappingImpl.INSTANCE.parse(
            new SupportDeskDto(
                Set.of("nope"),
                new String[] {"ada@example.org", "also-nope"},
                Map.of("bad-key", "a note")));
    // Invalid(NonEmptyList[
    //   agents.nope: not an email address,    <- a Set locates by the element itself
    //   standby.1: not an email address,      <- an array locates by index
    //   notes.bad-key: not an email address]) <- a key locates by the key it was sent as

A domain Optional against a plain nullable wire component is the bridge instead. A set has no index or reliable order, so the path names the element by its value. Keys and set elements render by toString(), so a dot inside one reads like deeper nesting, and two that render alike share a location, though every error is still reported. FieldError.path(), the segments array in a 422, keeps each as one segment.

An array that needs no conversion crosses as a clone. A record compares an array by reference, so a record with an array and no equals of its own is not equal to its own round trip.

The generated Impl calls a nested spec as it would a leaf, through its asValidatedPrism(), so recursion needs nothing special: a catalogue's Category(String name, List<Category> children) maps with an empty spec. A one-directional bean mapping nests for the direction it has.

Converting Map keys

A Map component's value leaf takes the component's name, so a key leaf carries @MapKey naming its component instead, as noteKey() does in SupportDeskMapping. Keys and values convert independently. Without a key leaf the key types must match exactly, and the processor refuses a mismatch, offering the annotation.

A key leaf may carry the component's @MapField rename only when it is named after the component it keys, since a rename renames the component its method is named after.

A failing key locates by the source key, the one the client sent, and an entry whose key and value both fail reports both there. A leaf over the whole Map wins over both: a key leaf beside a whole-map leaf says when that is refused.

Not checked for you: a set silently drops a collapsed element

A leaf that parses two wire values to one domain value ("1" and "01", say) breaks the section law: an accepted wire value must rebuild to exactly itself. ValidatedPrismLaws catches such a leaf, and ValidatedPrism.canonical rules it out. Where one slips through, a Set drops the duplicate silently. Two collapsed Map keys would lose a whole entry, so they are a located failure at the second key as it was sent, such as notes.01: duplicates an earlier key.


Flattening a nested component onto a flat wire

Nesting assumes the wire nests too. When a courier's API, fixed by someone else, lists a pickup point's Address as plain street, city and postcode fields, no single wire component holds it, so no leaf can map it. @Flatten on a marker named after the component spreads it instead:

// A courier's pickup point, whose API lists its address as plain fields.
record PickupPoint(String name, Address address) {} // Address as on Record Mapping Basics

record PickupPointDto(String name, String street, String city, String postcode) {} // flat

@GenerateMapping
interface PickupPointMapping extends MappingSpec<PickupPoint, PickupPointDto> {
  @Flatten
  Address address(); // spread by name: street, city and postcode
}


    PickupPointMappingImpl pickupMapping = PickupPointMappingImpl.INSTANCE;

    PickupPointDto flat =
        pickupMapping.build(
            new PickupPoint("Kirkstall", new Address("1 High Street", "Leeds", "LS1 4AP")));
    // PickupPointDto[name=Kirkstall, street=1 High Street, city=Leeds, postcode=LS1 4AP]
    Validated<NonEmptyList<FieldError>, PickupPoint> missing =
        pickupMapping.parse(new PickupPointDto("Kirkstall", null, "Leeds", null));
    // Invalid(NonEmptyList[address.street: must not be null, address.postcode: must not be null])
flowchart LR
    accTitle: A flattened address
    accDescr: Each member of the domain's Address record, address.street, address.city and address.postcode, pairs by name with the flat PickupPointDto field of the same name.
    subgraph D["PickupPoint.address"]
        AS["address.street"]
        AC["address.city"]
        AP["address.postcode"]
    end
    subgraph W["PickupPointDto"]
        S["street"]
        C["city"]
        P["postcode"]
    end
    AS <-->|same name| S
    AC <-->|same name| C
    AP <-->|same name| P

    classDef domain fill:#a6d189,stroke:#40a02b,color:#232634
    classDef wire fill:#8caaee,stroke:#1e66f5,color:#232634
    class AS,AC,AP domain
    class S,C,P wire

In words: each member of the address pairs with the flat field of the same name, so a bad street comes back as address.street.

The record's components, spread this way, are the group. parse builds the Address from those fields, with their failures accumulating beside the pickup point's, at the domain path address.street. The flat wire never sent that name, but like a rename, a path always uses domain names.

The vocabulary applies inside the group by name:

  • a @MapField rename points a member at a differently named wire field
  • a leaf converts a member, exactly as at the top level
  • an @OptionalBridge bridges an Optional member
  • a member that is itself a record nests through its own spec, and containers lift

A group whose members all copy as they are keeps asIso(), and a mapping carrying a group nests like any other. Flattening onto a bean wire, a generic spec, a projection or a sparse UpdateSpec is not supported yet: Where a flattened component can appear and Names in a flattened group have the rules.


Across modules

The spec a component nests through may live in another module. Keep the customer pair and CustomerMapping in :orders-api and the invoice pair in :billing, and InvoiceMapping still stays empty: its Impl delegates to the dependency's as it would to a sibling.

// :billing, which depends on :orders-api (Customer, CustomerDto and CustomerMapping live there)
@GenerateMapping
interface InvoiceMapping extends MappingSpec<Invoice, InvoiceDto> {}

// generated InvoiceMappingImpl.parse, the customer leg:
//   .field("customer", hkj$ifPresent(wire.customer(), CustomerMappingImpl.INSTANCE.asValidatedPrism()::parse))

There is nothing to configure, as long as :orders-api is compiled with hkj-processor on its processor path. A spec newly added to a dependency may need a clean downstream build before it is seen (Multi-module builds).

When several specs could map a pair, the processor picks one this way:

flowchart TD
    accTitle: Which spec maps a pair
    accDescr: One spec for the pair in this module wins. With none, a single dependency's spec is used. With none anywhere, the pair needs a leaf or the build stops. Two or more in this module are ambiguous until a leaf picks one; two or more in dependencies until a leaf or your own spec picks one.
    Q{"specs for the pair<br/>in this module?"} -->|one| O["yours wins"]
    Q -->|two or more| A1["ambiguous:<br/>a leaf picks one"]
    Q -->|none| D{"specs in<br/>dependencies?"}
    D -->|one| U["the dependency's"]
    D -->|two or more| A2["ambiguous: a leaf,<br/>or your own spec, picks one"]
    D -->|none| N["no usable source,<br/>unless a leaf converts it"]

    classDef decision fill:#e5c890,stroke:#df8e1d,color:#232634
    classDef tier fill:#a6d189,stroke:#40a02b,color:#232634
    classDef error fill:#e78284,stroke:#d20f39,color:#232634
    class Q,D decision
    class O,U tier
    class A1,A2,N error

In words: your own spec for the pair wins, and with none, a single dependency's is used. Two or more at either level fail with matches more than one mapping spec until you choose, so a new dependency never quietly takes over a pair. How a dependency's specs are found has the rest. A module with a module-info.java cannot resolve a dependency's specs yet.

Why this matters

The delegation is an ordinary static reference in generated code, resolved at compile time from the dependency's class files: no runtime registry, no reflection, no service file to keep in step. Rename or remove a spec upstream and the downstream build fails at the use site, with the pair named, rather than a request failing later.


Key Takeaways

  • Nesting is delegation: a spec for an inner pair is used wherever that pair appears, in this module or a dependency, and a List lifts it element by element
  • Error paths grow outward: each level puts its name in front, so the client reads customer.email or lines.1.price, and a sealed dispatch adds no segment
  • Sealed dispatch is exhaustive both ways: the processor refuses a missing subtype pair, so it never surprises you at runtime
  • A mapped List, Set or Map is unmodifiable: one that needs no conversion is also a copy, never the other side's own list

See Also


Previous: Absent Fields and Record Invariants Next: Capstone: One 422, Every Bad Field