Beans and Sparse PATCH
The same mapper for getter/setter wires, and the opt-in tier where null means "leave unchanged" instead of "broken".
Not every wire type is a record. Generated clients, JAXB payloads and legacy DTOs are beans, and one very common bean, the REST PATCH request, changes what null means: not broken data but not provided. This page covers both: mapping bean-shaped wires with the full feature set, and the explicit UpdateSpec opt-in that gives a PATCH bean its sparse semantics.
- Mapping bean-shaped wire types (setters, builders, JAXB lists) with the same features as records
- Why a bean mapping withholds
asIso(), and howOptionalbridges throughnull - Opting into sparse PATCH semantics with
UpdateSpec: present fields fold in, absent fields leave the domain alone - The rules that keep the null-as-absent contract honest, and how containers patch through the element vocabulary
The code on this page is RecordMappingBook.java - the page includes it directly, so it is compiled and run by the build.
Bean-shaped wire targets
The wire side need not be a record. A bean (a mutable class with a no-args constructor and getters/setters, or an immutable one with a builder) maps the same way, with the same features (renames, leaves, derived fields, container lifting, nesting). Only how the wire is read and written changes: build fills through setters or a builder, and parse reads through getters.
// A generated, mutable getter/setter DTO - not a record, so not annotatable. The spec still sits
// on your interface, never on the bean, so a third-party bean maps without being touched.
class ContactBean {
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 ContactMapping extends MappingSpec<Customer, ContactBean> {
// build() writes through setters; parse() reads through getters, null-guarded and located.
default ValidatedPrism<String, EmailAddress> email() {
return EmailCodecs.EMAIL;
}
}
Customer ada = new Customer("Ada", new EmailAddress("ada@corp.example"));
ContactBean bean =
ContactMappingImpl.INSTANCE.build(ada); // new ContactBean(); setName; setEmail
Validated<NonEmptyList<FieldError>, Customer> fromBean =
ContactMappingImpl.INSTANCE.parse(bean);
// A null bean property parses to a located FieldError, e.g. [email: must not be null].
The design decisions worth knowing:
- Null is located, never thrown, like every wire. The null guard is universal (one doctrine, both shapes), so a
nullproperty read is a locatedFieldErrorexactly as on a record wire. What is bean-specific is why nulls are expected at all: an unset property is a representable, ordinary state of a mutable bean, not just a hostile binding. - Honest tiers. Because an unset property is ordinary, a bean's guarded reference reads count as fallible and the mapping withholds
asIso()automatically; an all-primitive bean (whose reads can never be null) still earns it. A record wire's guards exist for hostile bindings only, so a lossless record mapping keepsasIso(), with the parse-iso coherence law scoped to wires whose reference components are non-null. Nesting is unaffected: a bean mapping exposesasValidatedPrism()like any other, so record specs nest it and containers lift it. - Construction strategy is detected from the bean's shape, tried in order: a public no-args constructor with
setXsetters (and, for a getter-onlyList, the JAXB conventiongetItems().addAll(...)); then a staticbuilder()/newBuilder()whose setters fill it and whosebuild()yields the wire. A bean that fits neither gets a what/why/fix diagnostic. - Optionality bridges through
null. On a full mapping, a domainOptional<T>maps to a nullable bean propertyT(bean conventions leaveOptionaloff property types): empty bridges to absent (buildskips the write, leaving the property unset;parsereadsOptional.ofNullable(...)), and a present value still validates through its leaf. The sparse tier is the deliberate exception: therenullalready means "leave unchanged", so a PATCH bean encodes "set to empty" with anOptional-typed property instead. - The domain stays a record.
parseassembles the domain through its canonical constructor, so only the wire may be bean-shaped; a bean domain gets a diagnostic.
A bean projection (a bean with fewer properties than the domain) with a reference property, and read-only (parse-only) or write-only (build-only) beans, are follow-ons. An all-primitive bean projection maps as a lawful asLens() today; a reference-typed one is reported with a pointer to the validated-patch tier, which ships for record wires; the bean flavour remains a follow-on.
Sparse PATCH write-back: UpdateSpec
A parse reads a null bean property as broken data: a located FieldError. But a REST PATCH body means the opposite: the client sends only the fields it wants to change, and every other property of the bound request arrives null, meaning not provided, leave unchanged. The two meanings of null are a property of the DTO's contract, not something the mapper can infer, so sparse semantics are an explicit opt-in: the spec extends UpdateSpec<Domain, Wire> instead of MappingSpec.
// A PATCH request bean. Here null means "not provided, leave unchanged" - the opposite of the bean
// parse above, where null is broken data. That contract is opted into by extending UpdateSpec.
class ContactPatchBean {
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 ContactPatchMapping extends UpdateSpec<Customer, ContactPatchBean> {
// Generates only updateFrom(ContactPatchBean) : Edits.Accumulated<Customer> - no build/parse/as*.
// A present field is set (email parsed through its leaf, located on failure); an absent (null)
// one is skipped, so the domain's current value survives.
default ValidatedPrism<String, EmailAddress> email() {
return EmailCodecs.EMAIL;
}
}
The Impl exposes a single method, updateFrom(Wire) : Edits.Accumulated<Domain>. There is no build, parse, or as* tier (a sparse mapping is not a projection of information, and an all-absent wire is valid, not a total parse). updateFrom folds the present properties into an Update<Domain>, leaving the absent ones alone:
Customer current = new Customer("Ada", new EmailAddress("ada@corp.example"));
ContactPatchBean patch = new ContactPatchBean();
patch.setName("Ada Lovelace"); // email left null: not provided, keep the current one
Edits.Accumulated<Customer> update = ContactPatchMappingImpl.INSTANCE.updateFrom(patch);
Validated<NonEmptyList<FieldError>, Customer> patched = update.apply(current);
// Valid(Customer[name=Ada Lovelace, email=ada@corp.example]) - only the name changed
- Present and valid → the field is set, or parsed through its leaf, and folded in.
- Present and invalid → a located
FieldError, accumulating as usual: sparseness never weakens validation of what was sent.Edits.Accumulatedalso offersapplyPath(current)to drop straight onto the validation railway, so a controller answers with every error at once instead of persisting a partial write (an all-FieldErrorpayload takes the 422 leg:hkj.web.validation-field-error-status, default 422). - Absent (null) → skipped; the domain's current value survives.
The return type is exactly what a hand-written Edits.accumulate(...) PATCH builder produces, so the two compose and the same consumption story (apply, applyPath, toValidated) carries over.
The rules that keep the contract honest:
- A primitive wire property is rejected. A primitive is always present (its default), so it can never carry the null-as-absent signal; use the wrapper type (
Integer,Boolean). This is forced, not a style choice: an all-absent body must fold to the identity update, which a primitive would break. - A domain
Optional<T>component bridged from a non-Optional property is rejected. Under null-as-absent,nullalready means "leave unchanged", so "set to empty" has no encoding through a plain property (and a null-clears rule would be JSON Merge Patch's opposite contract). AnOptional-typed wire property, though, can express it, patching by identity or through an element leaf: a present empty Optional sets empty; an absent (null) one leaves unchanged. Two binder caveats come with that power: Jackson binds an explicit JSONnullon anOptional-typed property toOptional.empty(), so on this one property shape a sentnullmeans clear, not leave unchanged; and the bean field must default tonull, not the idiomaticOptional.empty(), or every request that omits the field clears the domain value. - A record wire is rejected. A record component is always present, so absence is inexpressible; sparse PATCH is a bean-only shape.
- A sealed hierarchy is rejected, on either side: dispatch has no sparse meaning (an absent property cannot choose a subtype to patch).
- A present container parses through the element vocabulary. A
List,OptionalorMap-valued property (a pair declared as exactly those container types) routes through the element leaf named after the component: the same leaf the dense tiers lift, so one mix-in vocabulary serves a full spec and its PATCH sibling. Replacement stays wholesale; each failing element is located by index or key (phones.1). A whole-container leaf (ValidatedPrism<List<S>, List<A>>) is the more specific declaration and wins over the element interpretation. A nested spec still does not lift through a sparse container; give the component an element leaf delegating to the nested Impl'sasValidatedPrism()if its elements need a whole mapping. - Coverage is one-sided. Every wire property maps to a domain component, but a domain component with no wire property is simply never changed: a PATCH DTO deliberately covers a subset.
- A same-typed nested record,
Optional,ListorMapreplaces wholesale through identity, the fallback when no more specific leaf applies. A same-typedListorMapadditionally carries the dense tiers' null scan: a null element or value is a located, accumulating invalid (tags.1: must not be null), never written into the domain; a valid container still passes by reference, unrebuilt. The scan needs a properly parameterised container; a raw or wildcard-argument one is written as sent. A same-typedOptionalneeds no scan (it cannot hold a null element), so its identity write is unconditional: a present empty sets empty, absent leaves unchanged. A nested record whose wire differs is patched wholesale through its own full mapping spec. Deep merge is out of scope.
One vocabulary, both tiers. The element leaf a full spec lifts elementwise is exactly the leaf its PATCH sibling lifts:
record PhoneNumber(String value) {}
record Roster(String team, List<PhoneNumber> phones) {}
record RosterDto(String team, List<String> phones) {} // the full tier's wire
// A PATCH bean whose phones property is a whole-list replacement, absent when null.
class RosterPatchBean {
private String team;
private List<String> phones;
public String getTeam() {
return team;
}
public void setTeam(String team) {
this.team = team;
}
public List<String> getPhones() {
return phones;
}
public void setPhones(List<String> phones) {
this.phones = phones;
}
}
// ONE element vocabulary: the leaf names the component and parses ELEMENTS.
interface PhoneVocabulary {
default ValidatedPrism<String, PhoneNumber> phones() {
return ValidatedPrism.of(
raw ->
raw.startsWith("+")
? Validated.validNel(new PhoneNumber(raw))
: Validated.invalidNel(FieldError.of("not a phone number")),
PhoneNumber::value);
}
}
@GenerateMapping
interface RosterMapping extends PhoneVocabulary, MappingSpec<Roster, RosterDto> {}
// The full tier lifts the leaf elementwise: a bad element parses as phones.1.
@GenerateMapping
interface RosterPatchMapping extends PhoneVocabulary, UpdateSpec<Roster, RosterPatchBean> {}
// The sparse tier lifts the SAME leaf: a present list replaces wholesale, each element parsed,
// failures located phones.1 - one vocabulary, both tiers.
Roster roster = new Roster("core", List.of(new PhoneNumber("+44")));
RosterPatchBean rosterPatch = new RosterPatchBean();
rosterPatch.setPhones(List.of("+1", "nope")); // a present list replaces wholesale...
Validated<NonEmptyList<FieldError>, Roster> rosterPatched =
RosterPatchMappingImpl.INSTANCE.updateFrom(rosterPatch).apply(roster);
// ...but each element parses through the phones() leaf, located:
// Invalid(NonEmptyList[phones.1: not a phone number])
The sparse tier is law-checked like every other, through the same MappingLaws harness:
MappingLaws.assertMappingLaws(
ContactPatchMappingImpl.INSTANCE::updateFrom,
new Customer("Ada", new EmailAddress("ada@example.org")), // the current value
patch(null, null), // all-absent -> identity
patch("Grace", "grace@example.org"), // present valid -> changes the domain
patch(null, "not-an-email")); // present invalid -> located failure
Identity (an all-absent wire is the identity update), idempotence (applying the same patch twice equals applying it once, which holds because the generated edits set and parse, never modify), and validation (a present invalid field fails). The same laws hold over container elements:
MappingLaws.assertMappingLaws(
RosterPatchMappingImpl.INSTANCE::updateFrom,
new Roster("core", List.of(new PhoneNumber("+44"))), // the current value
rosterPatch(null, null), // all-absent -> identity
rosterPatch(null, List.of("+1", "+353")), // present valid -> wholesale replacement
rosterPatch(null, List.of("+1", "nope"))); // bad element -> located phones.1
The hkj-spring example app serves PATCH /api/users/{id} through exactly this tier; Sparse PATCH at the Spring boundary walks the controller, the not-found-plus-validation channel, and the slice test.
- Beans map with the full feature set: only the read/write mechanics differ, and the tiers stay honest (
asIsois withheld where unset properties make reads fallible) - Sparse semantics are an explicit opt-in:
UpdateSpecgives a PATCH bean null-as-absent; nothing is inferred from the shape alone - Sparseness never weakens validation: present fields still parse through their leaves, and every bad one is a located, accumulated
FieldError - One vocabulary serves both tiers: the element leaf a full spec lifts is the leaf its PATCH sibling lifts
- Multi-Edit and Sparse Updates - The hand-written
Edits.accumulatethis tier generates - Sparse PATCH at the Spring boundary - The controller story
- The Emission Tiers - Where
updateFromsits among the surfaces
Previous: The Emission Tiers Next: Generic Specs