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.

What You'll Learn

  • Mapping bean-shaped wire types (setters, builders, JAXB lists) with the same features as records
  • Why a bean mapping withholds asIso(), and how Optional bridges through null
  • 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

See Example Code

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 null property read is a located FieldError exactly 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 keeps asIso(), with the parse-iso coherence law scoped to wires whose reference components are non-null. Nesting is unaffected: a bean mapping exposes asValidatedPrism() 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 setX setters (and, for a getter-only List, the JAXB convention getItems().addAll(...)); then a static builder()/newBuilder() whose setters fill it and whose build() yields the wire. A bean that fits neither gets a what/why/fix diagnostic.
  • Optionality bridges through null. On a full mapping, a domain Optional<T> maps to a nullable bean property T (bean conventions leave Optional off property types): empty bridges to absent (build skips the write, leaving the property unset; parse reads Optional.ofNullable(...)), and a present value still validates through its leaf. The sparse tier is the deliberate exception: there null already means "leave unchanged", so a PATCH bean encodes "set to empty" with an Optional-typed property instead.
  • The domain stays a record. parse assembles the domain through its canonical constructor, so only the wire may be bean-shaped; a bean domain gets a diagnostic.

Not yet: bean projections and read-only beans

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.Accumulated also offers applyPath(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-FieldError payload 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, null already 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). An Optional-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 JSON null on an Optional-typed property to Optional.empty(), so on this one property shape a sent null means clear, not leave unchanged; and the bean field must default to null, not the idiomatic Optional.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, Optional or Map-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's asValidatedPrism() 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, List or Map replaces wholesale through identity, the fallback when no more specific leaf applies. A same-typed List or Map additionally 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-typed Optional needs 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 PATCH endpoint, end to end

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.


Key Takeaways

  • Beans map with the full feature set: only the read/write mechanics differ, and the tiers stay honest (asIso is withheld where unset properties make reads fallible)
  • Sparse semantics are an explicit opt-in: UpdateSpec gives 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

See Also


Previous: The Emission Tiers Next: Generic Specs