Mapping at the Boundary
"I, too, am a translated man. I have been borne across. It is generally believed that something is always lost in translation; I cling to the notion ... that something can also be gained." — Salman Rushdie, Shame
Every value that crosses a service boundary is translated in Rushdie's sense. It is born in the wire's language of strings and nulls, then borne across into the domain's language of typed identifiers, real dates, and emails that have already been checked. Rushdie insists something can be gained in translation, and this chapter takes him at his word: what lands on the far side is a value the domain can finally trust. The chapter is also about what an honest translator owes you when the original turns out to be gibberish: not the first complaint, but all of them, each with an address.
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:
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
}
return new Order(
UUID.fromString(dto.id()), // throws its own exception
new Customer(dto.customer().fullName(),
new EmailAddress(dto.customer().email())),
Instant.parse(dto.placedAt())); // and so does this one
}
It works, until it doesn't, and it fails three ways at once:
- It drifts. Add a component to
Orderand 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.
What you get instead
This chapter replaces that mapper with one interface you own and one annotation. The processor derives both directions at compile time wherever the wire supports them, and the fallible one reports every bad field at once, each located by a dotted path. Here is the destination, before any theory: a request with five defects, answered by one response.
{
"valid": false,
"errors": [
{ "path": "id", "message": "not a UUID (expected e.g. 123e4567-e89b-12d3-a456-426614174000)" },
{ "path": "customer.email", "message": "not an email address" },
{ "path": "lines.1.price", "message": "not a number in plain notation (expected e.g. 123.45)" },
{ "path": "placedAt", "message": "not an ISO-8601 instant (expected e.g. 2026-07-28T12:34:56Z)" },
{ "path": "status", "message": "unknown OrderStatus (expected one of NEW, PAID, SHIPPED)" }
],
"errorCount": 5
}
The bad email is inside a nested record; the bad price is on the second element of a list. The client fixes all five and resubmits once. Nobody wrote a line of error-handling code to produce this: it falls out of the declarations. The Capstone builds it end to end, and a test the build runs proves its five errors. This copy leaves out the segments array the full response carries beside each path.
From Record Mapping Basics on, the examples come from the order service behind that request, wherever its shapes fit. A Customer carries a checked EmailAddress, there is an Address to deliver to, an Order has its LineItems and its OrderStatus, and a sealed Payment pays for it. A page whose feature needs another shape adds a piece from the same service, such as a courier or a delivery window. Your attention stays on the feature rather than on a new pair of records. The Capstone puts the pieces together at full size.
The shape of the machinery is a railway with two directions:
flowchart LR
accTitle: The mapping railway, in both directions
accDescr: The parse method takes an OrderDto of strings and nulls and checks every field. If every field checks out, it yields a typed, trusted Order. If not, it yields a NonEmptyList of FieldError naming every bad field, which hkj-spring renders as one 422 response listing every error. The build method takes the Order back to an OrderDto and cannot fail.
DTO["OrderDto<br/>(strings and nulls)"] -->|"parse"| CHECK{"every field<br/>checks out?"}
CHECK -->|yes| DOM["Order<br/>(typed and trusted)"]
CHECK -->|no| ERR["NonEmptyList<FieldError><br/>every bad field, located"]
DOM -->|"build (total: cannot fail)"| OUT["OrderDto"]
ERR -->|"hkj-spring"| RESP["one 422 response<br/>listing every error"]
classDef wire fill:#8caaee,stroke:#1e66f5,color:#232634
classDef domain fill:#a6d189,stroke:#40a02b,color:#232634
classDef error fill:#e78284,stroke:#d20f39,color:#232634
classDef decision fill:#e5c890,stroke:#df8e1d,color:#232634
class DTO,OUT wire
class DOM domain
class ERR,RESP error
class CHECK decision
And the "one interface you own"? Here it is, whole, for a pair whose components already match:
record Address(String street, String city, String postcode) {}
record AddressDto(String street, String city, String postcode) {}
@GenerateMapping
interface AddressMapping extends MappingSpec<Address, AddressDto> {}
Where a field needs converting or checking, the spec (that interface) declares a leaf: the conversion at that one field, written 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;
}
}
Validated<NonEmptyList<FieldError>, Customer> parsed =
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 returns a Validated, typed Validated<NonEmptyList<FieldError>, Domain>. It is either Valid, holding your typed domain value, or Invalid, holding every defect at once in a NonEmptyList of FieldError values. Nothing drifts, because the processor re-derives the mapping from the records on every compile and rejects what it cannot honour.
Higher-Kinded-J is built on Java 25 today, with preview features enabled, and preview ties that build to one JDK release. The Quickstart has the one build line that sets that up, and ends at a working endpoint. Evaluating rather than building? Mapper at a Glance has the generated code, the costs and the decisions to know.
If you arrived here because you want that 422, the wiring is two steps: this chapter's parse, and the hkj-spring-boot-starter. The starter renders an Invalid parse result as one 422 Unprocessable Content response, with no code in between. Return the result from the controller as-is. The 422 leg is the full story, and Sparse PATCH at the Spring boundary covers PATCH endpoints. (A leg is the route a returned value travels to become an HTTP response, in the railway sense.)
Part of the library, not a bolt-on
The mapper is not a separate tool that happens to ship in the same jar; it is Higher-Kinded-J's own parts, composed and generated. That is why it fits the rest of the book:
- Parse, don't validate. In Alexis King's words, "a parser is just a function that consumes less-structured input and produces more-structured output."
parseis that function, returning a trusted value or a typed refusal, and the type system knows which. - Typed errors over exceptions. The refusal is a value (
Validated,NonEmptyList,FieldError), so it accumulates, composes, and travels the same railway as every other error in the library. - Truthful types. The generated surface only ever offers operations whose laws the record pair can honour; what cannot be lawful is simply not generated.
- Laws, verified. Each generated surface obeys stated laws, checked in the library's own build and repeatable in yours with one test call.
- Built from parts you already know. A leaf is a
ValidatedPrism, accumulation isValidated.fields(), a sparse PATCH folds intoEdits.Accumulated. Learn the mapper and you have learned more of the library; learn the library and the mapper works the way you would expect.
What the mapper will (and will not) generate
The generated surface follows the shape of the pair:
flowchart TD
accTitle: What the mapper generates for each pairing
accDescr: When every component matches by name and type, the mapping round-trips both ways, lawfully. When some fields differ in type and a leaf, codec or nested spec converts them, parse reports every bad field, located. When the wire has fewer components than the domain, the write-back keeps the fields the wire dropped, and if any field converts or validates, that write-back can fail, located. A PATCH request bean, where null means leave unchanged, gets a sparse update in which absent means keep.
Q{"How do the two records<br/>correspond?"}
Q --> A["Every component matches<br/>by name and type"]
Q --> B["Some fields differ in type:<br/>leaves, codecs, nested specs"]
Q --> C["Wire has fewer components<br/>than the domain"]
A --> AT(["round-trips both ways,<br/>lawfully"])
B --> BT(["a parse that reports<br/>every bad field, located"])
C --> CQ{"Any converting or<br/>validating field?"}
CQ -->|no| CT(["a write-back that keeps<br/>the fields the wire dropped"])
CQ -->|yes| DT(["a write-back that<br/>can fail, located"])
Q --> P["A PATCH request bean, where<br/>null means leave unchanged"]
P --> PT(["a sparse update:<br/>absent means keep"])
classDef wire fill:#8caaee,stroke:#1e66f5,color:#232634
classDef tier fill:#a6d189,stroke:#40a02b,color:#232634
classDef decision fill:#e5c890,stroke:#df8e1d,color:#232634
class A,B,C,P wire
class AT,BT,CT,DT,PT tier
class Q,CQ decision
What Your Spec Generates names each of these surfaces and the laws it obeys. Some of them are optics the book teaches elsewhere. When every component is a plain copy, the spec also gets an Iso, a lossless two-way conversion. When the wire has fewer components and each is a plain copy, it gets a Lens, which writes the wire onto a domain value you already hold. Which methods your spec gets says what counts as a plain copy.
This is not a MapStruct competitor on breadth, and does not try to be. MapStruct keeps its ground for mutable JPA entities, for deep path flattening (address.geo.lat onto a wholly flat wire, where this generator spreads one level), and for 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 the processor generates no operation whose laws the pair cannot satisfy. Adopt it where the boundary is the product; keep MapStruct where its breadth pays. One MapStruct habit does not carry over: declaring the mapper's instance on its own interface. Here that constant can read null, so bind the generated Impl in the caller instead.
The usual pipeline is: bind with Jackson, annotate the DTO with @Valid constraints, translate with a mapper, and catch what leaks in a @ControllerAdvice. To be fair to that stack, @Valid does accumulate errors, and they do carry field names. What it cannot do is produce Order. The annotations guard the DTO, while 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. Here, parsing and validating are one step, and the type system knows it happened.
How to read this chapter
Start from what you came for.
| You want | Start at |
|---|---|
| A working endpoint that answers with a located 422 | Quickstart, five steps |
| To understand the model before writing any of it | Record Mapping Basics, then each page through to the Capstone and Check Your Understanding |
| To judge whether it fits your services | Mapper at a Glance |
| To bring a MapStruct or Bean Validation habit across | Coming from MapStruct and Bean Validation |
| To see it working on one boundary | the Capstone |
| To see it across modules, with a client jar, a Lombok bean and a PATCH | the estate capstone |
Those pages teach the model and put it behind an endpoint. For a particular task, go straight to its page:
- A field the client may leave out, read as an empty
Optional, or a record whose constructor refuses bad values: Absent Fields and Record Invariants - DTOs that nest, hold lists, or dispatch over sealed types: Nesting, Containers, and Sealed Hierarchies
- What exactly got generated for your spec, and why: What Your Spec Generates
- A getter/setter or builder DTO: Bean-Shaped Wires
- A PATCH endpoint, where an omitted field keeps its current value: Sparse PATCH
- A
Page<T>at the boundary: Generic Specs - Combining several sources, or typing your error context: Merge and Error Envelopes
- A rule, a limit, or a runtime surprise to look up: Rules and Limits
- A compiler message to decode: Compiler Messages
- Spring beans, test fakes, and how wide a record may be: Injecting, Testing, and Diagnostics
Practise the whole lane in the Boundary Mapping Journey (4 tutorials, 19 exercises, ~50 minutes): hand-written multi-edits, the ValidatedPrism leaf, the generated boundary of Tutorial 26, and the edge cases of Tutorial 27.
Chapter Contents
Ship, read in order:
- Quickstart: Your First 422: Five steps from a blank build to a located 422
- Record Mapping Basics: Your first mapping, leaves, renames, derived fields
- Standard Codecs and Shared Vocabulary: Stock lawful codecs and mix-in sharing
- Absent Fields and Record Invariants: Optional fields and a record's own checks
- Nesting, Containers, and Sealed Hierarchies: Composition and dotted error paths
- Capstone: One 422, Every Bad Field: One boundary built end to end, proven
- Check Your Understanding: Ten questions, answers proved by the build
On demand, when a task calls for it:
- What Your Spec Generates: Truthful types, projections, the validated patch, laws
- Bean-Shaped Wires: Setter, builder, and one-directional bean wires
- Sparse PATCH:
UpdateSpec: an omitted field keeps its value - Generic Specs: Concrete, threaded, and element-mapped generics
- Merge and Error Envelopes: Multi-source assembly and typed error context
- Injecting, Testing, and Diagnostics: Spring beans, test fakes, and record width
- Capstone: An Estate in Three Modules: A vocabulary, a client jar, Lombok, and PATCH across modules
Look it up, when you hold a question:
- Mapper at a Glance: Generated code, costs, and adoption decisions
- Coming from MapStruct and Bean Validation: Your vocabulary, translated
- Rules and Limits: Every enforced rule and limit, in one place
- Compiler Messages: The common refusals, what each means, and the fix