Mapping at the Boundary
"A parser is just a function that consumes less-structured input and produces more-structured output." — Alexis King, Parse, Don't Validate
Every service has the same three files. A DTO the framework binds. A domain record the business logic trusts. And, between them, a mapper: hand-written, reflection-driven, or generated, but always there, because the shape the wire speaks is never quite the shape the domain thinks in.
Here is the version most codebases carry, in one form or another:
public static User toDomain(UserDto dto) {
Objects.requireNonNull(dto.email(), "email required"); // throws on the FIRST problem
if (!dto.email().contains("@")) {
throw new IllegalArgumentException("bad email"); // no field name, no path
}
return new User(
UUID.fromString(dto.id()), // throws its own exception
dto.email(),
LocalDate.parse(dto.joined())); // and so does this one
}
It works, until it doesn't, and it fails three ways at once:
- It drifts. Add a component to
Userand nothing tells you the mapper no longer covers it. The compiler is not watching this file. - It stops at the first error. The client fixes the email, resubmits, and only then learns the date was bad too. One round trip per defect.
- Its errors have no address.
IllegalArgumentException: bad emailsays nothing a client can map onto a form field, so a handler somewhere turns it into a vague 400.
The usual patch is a pipeline: bind with Jackson, annotate the DTO with Bean Validation, translate with a mapper, and catch what leaks in a @ControllerAdvice. And, to be fair to that stack, @Valid does accumulate errors, and they do carry field names. What it cannot do is produce User. The annotations guard the DTO; the domain constructor still runs on data that was checked somewhere else; the format rule lives in a third place neither record enforces; and the mapper in the middle can still throw. Parsing and validating stay separate steps, and the type system never learns that either happened.
This chapter replaces that pipeline with one step, derived at compile time from an interface you own. Where a field needs converting or checking, the spec declares a leaf: the conversion at that one field, as a ValidatedPrism:
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;
}
}
CustomerMappingImpl.INSTANCE.parse(new CustomerDto("Bob", "not-an-email"));
// Invalid(NonEmptyList[email: not an email address])
Outbound, build is a total function: it cannot fail. Inbound, parse is a parser in Alexis King's sense: it returns your typed domain value, or Validated<NonEmptyList<FieldError>, Domain> carrying every bad field at once, each located by a dotted path. Nothing drifts, because the processor re-derives the mapping from the records on every compile and rejects what it cannot honour. And in a Spring controller the parse result is already a response: one 422 listing every defect, travelling the 422 leg (a leg is the route a returned value travels to become an HTTP response, in the railway sense).
What the mapper will (and will not) generate
The generated surface follows the shape of the pair. That decision is the map of this chapter:
How do the two records correspond?
│
┌───────────────────────────┼───────────────────────────┐
▼ ▼ ▼
every component some fields differ wire has FEWER
matches by name in type: leaves, components than
and type codecs, nested specs the domain
│ │ │
▼ ▼ ┌─────────┴─────────┐
reversible both ways one-way render, plus ▼ ▼
(build, a guarded an accumulating all identity: any converting
parse, a lawful asIso) parse back a lawful field: a
(the round trip write-back write-back that
can reject) lens (asLens) can fail (patch)
...and a PATCH request bean, where null means "leave unchanged"?
extend UpdateSpec ──▶ updateFrom only
Nothing here is fabricated: a mapping only offers the operations its field correspondences can lawfully support. The Emission Tiers names each of these surfaces and the laws it obeys, verified in the library's own build and repeatable in yours with one test call.
This is not a MapStruct competitor on breadth, and does not try to be: MapStruct keeps its ground for mutable JPA entities, nested-path flattening, and Bean-Validation-centric shops. What this generator does differently is boundary correctness for record domains: the inbound direction is a validating parser with located, accumulated errors (where MapStruct throws on the first bad conversion, or silently maps an invalid value), the outbound direction is provably total, and no operation is generated whose laws the pair cannot satisfy. Adopt it where the boundary is the product; keep MapStruct where its breadth pays.
- Record Mapping Basics – The
MappingSpecinterface, the totalbuild/ accumulatingparseasymmetry, the null doctrine that turns every wirenullinto a located error,ValidatedPrismleaves,@MapFieldrenames, and derived wire-only fields. - Standard Codecs and Shared Vocabulary – The stock leaf vocabulary (
uuid(),localDate(),enumByName(...), money and more), why it accepts canonical forms only,ValidatedPrism.canonicalfor your own canons, and mix-in interfaces that share leaves across an API. - Nesting, Containers, and Sealed Hierarchies – Specs nest automatically and failures compose into dotted paths;
List/Optional/Maplift their elements; sealed pairs dispatch exhaustively in both directions. - The Emission Tiers – The truthful-types table: which shapes earn
asIso(),asLens(), the validatedpatch, orasValidatedPrism(), and theMappingLawscall that proves each tier lawful in your own tests. - Beans and Sparse PATCH – Getter/setter and builder wires with the full feature set, and the
UpdateSpecopt-in that gives a PATCH bean null-as-absent semantics without weakening validation of what was sent. - Generic Specs – Concrete instantiations, threaded type parameters, and element-mapped specs whose codecs arrive at construction time.
- Merge and Error Envelopes –
@GenerateMergeassembles one target from several sources with truthful return types;@GenerateErrorEnveloperetires the copy-pastedcode/message/timestampand types the error context. - Injecting, Testing, and Diagnostics – Register the surface you consume, fake codecs as two-line values, and lean on what/why/fix diagnostics; there is no component ceiling.
- Capstone: One 422, Every Bad Field – The whole chapter on one order-intake boundary: a five-defect request answered by a single located-errors response, with PATCH, merge, and envelope encores, all proven by a green test.
Practise the whole lane in the Boundary Mapping Journey (3 tutorials, 13 exercises, ~35 minutes): hand-written multi-edits, the ValidatedPrism leaf, and the generated boundary of Tutorial 26.
Chapter Contents
- Record Mapping Basics - The spec interface, build/parse asymmetry, leaves, renames
- Standard Codecs and Shared Vocabulary - Stock lawful codecs and mix-in sharing
- Nesting, Containers, and Sealed Hierarchies - Composition and dotted error paths
- The Emission Tiers - Truthful types, projections, the validated patch, laws
- Beans and Sparse PATCH - Bean wires and the UpdateSpec tier
- Generic Specs - Concrete, threaded, and element-mapped generics
- Merge and Error Envelopes - Multi-source assembly and typed error context
- Injecting, Testing, and Diagnostics - Beans, fakes, and limits
- Capstone: One 422, Every Bad Field - The whole chapter on one boundary, proven
Next: Record Mapping Basics