Capstone: One 422, Every Bad Field

"Make illegal states unrepresentable." — Yaron Minsky

What You'll Learn

  • One order-intake boundary built end to end: codecs, a custom leaf, a rename, nesting, a list, and a derived field
  • The payoff: a five-defect request answered by a single response naming every bad field by path
  • A preview of three later pages on the same boundary: a sparse PATCH, a multi-source merge, and a typed error envelope
  • The laws test that proves all of it, copied from a green build

See Example Code

The code on this page is BoundaryCapstoneBook.java and its BoundaryCapstoneBookLawsTest.java - the page includes both directly, so every Java block on this page is compiled, run, and asserted by the build.


The Scenario

An order-intake API. The domain is what the business logic trusts: typed identifiers, a real Instant, a real Currency, an email that has already been checked.

enum OrderStatus {
  NEW,
  PAID,
  SHIPPED
}

record EmailAddress(String value) {}

record Customer(String name, EmailAddress email) {}

// Integer, not int: a leaf converts the quantity, and a leaf cannot name a primitive.
record LineItem(String sku, Integer quantity, BigDecimal price) {}

record Order(
    UUID id,
    Customer customer,
    List<LineItem> lines,
    Instant placedAt,
    Currency currency,
    OrderStatus status) {}

The wire is what clients actually send: strings, a rename (fullName), and one field the domain does not store because it can be computed:

record CustomerDto(String fullName, String email) {}

record LineItemDto(String sku, String quantity, String price) {}

record OrderDto(
    String id,
    CustomerDto customer,
    List<LineItemDto> lines,
    String placedAt,
    String currency,
    String status,
    String displayTotal) {} // no domain counterpart: derived on build, ignored on parse

Every value a leaf converts arrives as a String, the quantity included, so parse can locate a bad one. A client can still send the quantity as a JSON number, which Jackson binds as its digits. Standard codecs weighs this against an Integer wire field, where a malformed quantity fails inside Jackson and 2.5 quietly becomes 2.

The task: accept an OrderDto, return a trusted Order, and when the request is bad, tell the client everything that is wrong with it, in one round trip, with every problem located.


The Imperative Approach

The chapter opened with a sketch of this mapper; here it is at full scale:

  /** The hand-written version this chapter replaces: throws on the first problem, unlocated. */
  static Order toDomainByHand(OrderDto dto) {
    Objects.requireNonNull(dto.customer(), "customer required"); // first null wins
    if (!dto.customer().email().contains("@")) {
      throw new IllegalArgumentException("bad email"); // no field name, no path
    }
    List<LineItem> lines = new ArrayList<>();
    for (LineItemDto line : dto.lines()) {
      lines.add(
          new LineItem(
              line.sku(), Integer.parseInt(line.quantity()), new BigDecimal(line.price())));
    }
    return new Order(
        UUID.fromString(dto.id()), // throws its own exception
        new Customer(dto.customer().fullName(), new EmailAddress(dto.customer().email())),
        List.copyOf(lines),
        Instant.parse(dto.placedAt()), // and so does this one
        Currency.getInstance(dto.currency()), // and this one
        OrderStatus.valueOf(dto.status())); // and this one
  }

Count the ways the five-defect request defeats it. The mapper reports one problem, whichever throws first, and its message carries no field path. Eight call sites throw four different exception types (NullPointerException, IllegalArgumentException, DateTimeParseException, NumberFormatException). Since a NumberFormatException is an IllegalArgumentException, three unrelated catch clauses cover them. And when Order grows a component next quarter, nothing warns that this method no longer covers it.


The HKJ Approach

One hand-written leaf for the email (everything else is stock), shared through a vocabulary interface:

/** The one hand-written leaf; everything else comes from StandardCodecs. */
final class Codecs {
  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 Codecs() {}
}

/** Shared vocabulary: the same email leaf serves the full mapping and its PATCH sibling. */
interface OrderVocabulary {
  default ValidatedPrism<String, EmailAddress> email() {
    return Codecs.EMAIL;
  }
}

Three specs declare the whole boundary. Every conversion is named after its component; the processor derives both directions and rejects anything it cannot honour:

@GenerateMapping
interface CustomerMapping extends OrderVocabulary, MappingSpec<Customer, CustomerDto> {
  @MapField(to = "fullName")
  String name(); // the wire spells it differently
}

@GenerateMapping
interface LineItemMapping extends MappingSpec<LineItem, LineItemDto> {
  default ValidatedPrism<String, Integer> quantity() {
    return intFromString();
  }

  default ValidatedPrism<String, BigDecimal> price() {
    return bigDecimal();
  }
}

@GenerateMapping
interface OrderMapping extends MappingSpec<Order, OrderDto> {
  default ValidatedPrism<String, UUID> id() {
    return uuid();
  }

  default ValidatedPrism<String, Instant> placedAt() {
    return instant();
  }

  default ValidatedPrism<String, Currency> currency() {
    return StandardCodecs.currency(); // qualified: the leaf shares the factory's name
  }

  default ValidatedPrism<String, OrderStatus> status() {
    return enumByName(OrderStatus.class);
  }

  // A wire-only component, computed from the whole domain value on build:
  default Getter<Order, String> displayTotal() {
    return Getter.of(
        order ->
            order.currency().getCurrencyCode()
                + " "
                + order.lines().stream()
                    .map(line -> line.price().multiply(BigDecimal.valueOf(line.quantity())))
                    .reduce(BigDecimal.ZERO, BigDecimal::add));
  }
}

That is the entire declaration: no mapper class, no Bean Validation annotations, no exception handler. It is not shorter than the hand-written mapper: three small interfaces, a vocabulary, and one custom leaf, against a nineteen-line method. What changed is what each line does, since these lines also carry the validation, the locations, the reverse direction, and the laws. build is total, and the derived displayTotal is computed, not copied:

    Order order =
        new Order(
            UUID.fromString("123e4567-e89b-12d3-a456-426614174000"),
            new Customer("Ada Lovelace", new EmailAddress("ada@corp.example")),
            List.of(new LineItem("SKU-1", 2, new BigDecimal("9.99"))),
            Instant.parse("2026-07-28T12:34:56Z"),
            Currency.getInstance("GBP"),
            OrderStatus.PAID);

    OrderDto outbound = OrderMappingImpl.INSTANCE.build(order); // total: cannot fail
    // OrderDto[id=123e4567-..., customer=CustomerDto[fullName=Ada Lovelace, ...],
    //          placedAt=2026-07-28T12:34:56Z, currency=GBP, status=PAID,
    //          displayTotal=GBP 19.98]   <- computed by the derived getter

The Payoff

Now the five-defect request, with a prediction first.

Checkpoint: count the errors first

The hostile request has a bad id, a bad email inside the nested customer, and a bad price on the second line item. Its timestamp is in a format the codec does not speak, its status is unknown, and its displayTotal is null.

Before you read the result: how many located errors does parse report, and is the null one of them?

Answer and why

Five, and the null is not among them. displayTotal is a derived field: build computes it from the whole domain value and parse never reads it, so the null guard, which covers every value parse does read, has nothing to guard here. The Proof asserts the five, in order.

Where this lives: Derived wire fields.

    OrderDto hostile =
        new OrderDto(
            "NOPE", // not a UUID
            new CustomerDto("Ada Lovelace", "not-an-email"), // fails the email leaf
            List.of(new LineItemDto("SKU-1", "2", "9.99"), new LineItemDto("SKU-2", "1", "1E+3")),
            "28/07/2026", // not an ISO-8601 instant
            "GBP",
            "DISPATCHED", // not a permitted OrderStatus
            null); // derived: parse ignores it

    Validated<NonEmptyList<FieldError>, Order> parsed = OrderMappingImpl.INSTANCE.parse(hostile);
    // Invalid(NonEmptyList[
    //   id: not a UUID (expected e.g. 123e4567-e89b-12d3-a456-426614174000),
    //   customer.email: not an email address,
    //   lines.1.price: not a number in plain notation (expected e.g. 123.45),
    //   placedAt: not an ISO-8601 instant (expected e.g. 2026-07-28T12:34:56Z),
    //   status: unknown OrderStatus (expected one of NEW, PAID, SHIPPED)])

Five defects, one value, every error located, in declaration order. In a Spring controller this result needs no wrapping: return it as-is and the 422 leg renders it as one response, the very response the chapter's introduction promised:

{
  "valid": false,
  "errors": [
    { "path": "id",             "segments": ["id"],                    "message": "not a UUID (expected e.g. 123e4567-e89b-12d3-a456-426614174000)" },
    { "path": "customer.email", "segments": ["customer", "email"],     "message": "not an email address" },
    { "path": "lines.1.price",  "segments": ["lines", "1", "price"],   "message": "not a number in plain notation (expected e.g. 123.45)" },
    { "path": "placedAt",       "segments": ["placedAt"],              "message": "not an ISO-8601 instant (expected e.g. 2026-07-28T12:34:56Z)" },
    { "path": "status",         "segments": ["status"],                "message": "unknown OrderStatus (expected one of NEW, PAID, SHIPPED)" }
  ],
  "errorCount": 5
}

The client fixes all five and resubmits once, where the hand-written mapper would have surfaced them one 400 at a time. A Bean Validation stack accumulates better than that, but its errors describe the DTO. The five here describe the domain's components, by their domain names even where the wire renames one (Renames). They come from the same declarations that produce the Order, and that parse is the only way from the wire into the domain.

What happened, error by error

Located errorMachinery that produced it
id: not a UUID (...)the stock uuid() codec
customer.email: not an email addressthe hand-written leaf, located through the nested spec
lines.1.price: not a number in plain notation (...)bigDecimal(), which accepts canonical forms only, in the spec the list lifts, located by element index
placedAt: not an ISO-8601 instant (...)instant(), rejecting a format it does not speak
status: unknown OrderStatus (...)enumByName(...), naming the permitted constants

Each error came from a page before this one, and no error-handling code was written to produce any of them.


The Encores

The same boundary, two more mappings in a handful of lines, and a third generator described in a paragraph. Each previews a later page and links to it.

A sparse PATCH. The email leaf is already in the vocabulary, so the PATCH sibling is one bean and one empty spec (Sparse PATCH covers it in full). Absent means keep; a present bad value still fails, located:

/** The PATCH request bean: a null property means "not provided, leave unchanged". */
class CustomerPatchBean {
  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 CustomerPatchMapping extends OrderVocabulary, UpdateSpec<Customer, CustomerPatchBean> {}

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

    CustomerPatchBean patchBody = new CustomerPatchBean();
    patchBody.setEmail("countess@lovelace.example"); // name omitted: null, keep the current one

    Validated<NonEmptyList<FieldError>, Customer> patched =
        CustomerPatchMappingImpl.INSTANCE.updateFrom(patchBody).apply(current);
    // Valid(Customer[name=Ada Lovelace, email=EmailAddress[value=countess@lovelace.example]])

A receipt, merged. One target from two sources, filled by component name, no class literals, by @GenerateMerge:

record Receipt(UUID id, String name, EmailAddress email, Instant placedAt) {}

@GenerateMerge
interface ReceiptAssembly {
  Receipt assemble(Order order, Customer customer);
}

    Receipt receipt = ReceiptAssemblyImpl.INSTANCE.assemble(order, order.customer());
    // Receipt[id=123e4567-..., name=Ada Lovelace, email=..., placedAt=2026-07-28T12:34:56Z]

A typed error envelope. When the service behind a boundary like this fails, its sealed error hierarchy tends to re-declare code/message/timestamp/context on every variant, with context an untyped Map<String, Object>. @GenerateErrorEnvelope generates that envelope and types the context; its running example is an order-domain OrderError, worked in full on the Merge and Error Envelopes page.


The Proof

"Lawful" is a passing test, not an adjective. First, a test asserts the payoff message for message, in order. The five-defect wire must produce exactly those five located errors:

    OrderDto hostile =
        new OrderDto(
            "NOPE",
            new CustomerDto("Ada Lovelace", "not-an-email"),
            List.of(new LineItemDto("SKU-1", "2", "9.99"), new LineItemDto("SKU-2", "1", "1E+3")),
            "28/07/2026",
            "GBP",
            "DISPATCHED",
            null);

    Validated<NonEmptyList<FieldError>, Order> parsed = OrderMappingImpl.INSTANCE.parse(hostile);

    assertThatValidated(parsed)
        .isInvalid()
        .hasFieldErrors(
            "id: not a UUID (expected e.g. 123e4567-e89b-12d3-a456-426614174000)",
            "customer.email: not an email address",
            "lines.1.price: not a number in plain notation (expected e.g. 123.45)",
            "placedAt: not an ISO-8601 instant (expected e.g. 2026-07-28T12:34:56Z)",
            "status: unknown OrderStatus (expected one of NEW, PAID, SHIPPED)");

Second, the full mapping obeys the fallible tier's laws (round trip on a parsing wire, guaranteed rejection on a non-parsing one, coherence with build), through the same MappingLaws harness the library's own build runs:

    // Fallible tier: a wire that parses (its derived displayTotal matching what build
    // would produce, keeping the no-parse check honest), and a wire that must not.
    MappingLaws.assertMappingLaws(
        OrderMappingImpl.INSTANCE.asValidatedPrism(),
        new OrderDto(
            "123e4567-e89b-12d3-a456-426614174000",
            new CustomerDto("Ada Lovelace", "ada@corp.example"),
            List.of(new LineItemDto("SKU-1", "2", "9.99")),
            "2026-07-28T12:34:56Z",
            "GBP",
            "PAID",
            "GBP 19.98"),
        new OrderDto(
            "NOPE",
            new CustomerDto("Ada Lovelace", "ada@corp.example"),
            List.of(),
            "2026-07-28T12:34:56Z",
            "GBP",
            "PAID",
            null));

Third, the PATCH sibling obeys the sparse laws: an all-absent form is the identity update, a valid present field changes the domain, an invalid one fails located:

    MappingLaws.assertMappingLaws(
        CustomerPatchMappingImpl.INSTANCE::updateFrom,
        new Customer("Ada Lovelace", new EmailAddress("ada@corp.example")), // the current value
        new CustomerPatchBean(), // all-absent, as bound from {} -> identity
        patch(null, "countess@lovelace.example"), // present valid -> changes the domain
        patch(null, "not-an-email")); // present invalid -> located failure

Key Takeaways

  • The whole boundary is three small interfaces: one custom leaf, stock codecs for the rest, a rename, a derived field; the processor derives both directions and keeps them covering the records
  • One response, every bad field: nesting, list indices, and codec messages compose into located errors a client can map straight onto its form
  • The encores are almost free: the PATCH sibling reuses the vocabulary, the merge is a method signature, the envelope is one component
  • All of it is proven: the page's payoff and laws are includes from a green test

See Also


Previous: Nesting, Containers, and Sealed Hierarchies Next: Check Your Understanding