Bean-Shaped Wires
Map getter/setter and builder classes with the same features as records, including ones only read or written.
Generated client models, JAXB payloads and many legacy DTOs are beans: classes with getters and setters, or a builder, rather than records. They map with the same leaves, renames and derived fields that Record Mapping Basics teaches for records. This page covers what changes, including a bean that is only ever read or only ever written. A bean used as a PATCH request, where null means not sent, has a page of its own, Sparse PATCH.
- Mapping bean-shaped wire types (setters, builders, JAXB lists) with the same features as records
- Why a bean mapping withholds
asIso(), and howOptionalbridges throughnullhere without a declaration - Why a bean projection with a reference property takes the validated
patchrather thanasLens() - Keeping an accessor out of the mapping on purpose, with
@Unmapped - Mapping a bean that can only be read, or only be written:
parsealone orbuildalone, and where each nests
The code on this page is BeansBook.java and its BeansBookTest.java - the page includes them directly, so they are 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"));
var contactMapping = ContactMappingImpl.INSTANCE;
ContactBean bean = contactMapping.build(ada); // new ContactBean(); setName; setEmail
Validated<NonEmptyList<FieldError>, Customer> fromBean = contactMapping.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 rule, 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 and whose values the domain accepts. Nesting is unaffected: a bean mapping that builds and parses exposesasValidatedPrism()like any other, so record specs nest it and containers lift it, and a one-directional one exposes the half it has, nesting wherever only that direction is used. - 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 with getters that fits neither is only ever read, so it maps parse-only; a bean with nothing to read or write gets a what/why/fix diagnostic. - A property is a getter and a writer that share a name. Getters are
getX(), andisX()returningbooleanorBoolean(the shape JAXB declares for an optional boolean); where a bean declares both for one name,getX()reads it. A getter nothing writes, or a writer nothing reads, is left out of the mapping, which suits a computed getter such asgetSummary()or a builder's singular adder. When an unpaired accessor is named after a domain component the bean carries under no name, the one the component maps under (its own, or the one a@MapFieldrename gives it), leaving it out would drop that component without a word, so it is refused. The diagnostic names the fix: when an unpaired accessor is refused lists what it offers, and Accessors meant to stay out covers the marker for one left out on purpose. - Optionality bridges through
null, automatically here. On a full mapping, a domainOptional<T>maps to a nullable bean propertyT(bean conventions leaveOptionaloff property types): empty bridges to absent (buildwritesnull, replacing whatever the bean or its builder started with;parsereadsOptional.ofNullable(...)), and a present value still validates through its leaf, or nests through its own spec. This is the only wire shape where the bridge needs no declaration: a record wire opts in per component with@OptionalBridge, which buys the same correspondence, and declaring it on a bean spec is redundant (a note, not an error, so one mix-in can serve both shapes). One property shape refuses the bridge, a getter-onlyList, which has no unset state to carry absence. The sparse tier is the deliberate exception in the other direction: therenullalready means "leave unchanged", so a PATCH bean encodes "set to empty" with anOptional-typed property instead. - A bridged property's writer must take
null.buildnever skips a write, so a bean's own defaults (a field initialiser, a builder's default) cannot survive it and read back as present. The price is that an emptyOptionalreaches the setter or builder setter asnull. A setter that copies defensively needs a guard (v == null ? null : List.copyOf(v)). A parameter declared non-null, by a non-null annotation or by a@NullMarkedscope with no@Nullableon it, is refused, as a bridged record component is: mark it@Nullable(on a Lombok bean, on the field, which Lombok copies to the setter). A generated builder that refusesnull(protobuf, Immutables) cannot be changed, so declare the component without theOptional, or give it a leaf over the wholeOptionalthat encodes absence the builder's way (ValidatedPrism<String, Optional<String>>mapping empty to""), which wins over the bridge. A default the bean applies to anullit is given, in the setter, a builder'sbuild()or the getter, still reads back as present;MappingLawscatches it. - 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.
The precise rules for bean wires, from an unpaired accessor to what a getter-only List must declare, are in Bean wires.
Bean projections
A bean with fewer properties than the domain is a projection, as a smaller record wire is, but the same shape can land on a different tier. A record is constructed whole, so a record projection that copies by identity keeps its lawful asLens(): a null component there is a hostile binding, not a state the type invites. A bean is constructed empty and filled by setters, which makes an unset reference property an ordinary state, and a lens's set cannot fail, so it has no honest answer for one. A bean projection with any reference property therefore takes the validated patch, even when every property copies by identity:
// A bean carrying one of Employee's three components: a projection. A record wire of that shape
// copies by identity and keeps asLens(); a bean's reference property can be unset, which a lens's
// set could not refuse, so the Impl emits the validated patch instead.
class TransferBean {
private String department;
public String getDepartment() {
return department;
}
public void setDepartment(String department) {
this.department = department;
}
}
@GenerateMapping
interface TransferMapping extends MappingSpec<Employee, TransferBean> {}
Employee researcher = new Employee("Ada", "Research", 36);
TransferBean transfer = new TransferBean();
transfer.setDepartment("Platform");
var transferMapping = TransferMappingImpl.INSTANCE;
// The bean's property can be unset, so the projection validates: patch, never a lens.
Validated<NonEmptyList<FieldError>, Employee> transferred =
transferMapping.patch(researcher, transfer);
// Valid(Employee[name=Ada, department=Platform, age=36])
// Dense, as on a record wire: an unset property is a located error, never "keep the current
// value".
Validated<NonEmptyList<FieldError>, Employee> unset =
transferMapping.patch(researcher, new TransferBean());
// Invalid(NonEmptyList[department: must not be null])
Everything else is the record-wire tier unchanged: every projected property is validated, every bad one is located and accumulated, and the unprojected components are read from the domain argument, so they survive by construction. Leaves, nested specs and container lifting all apply, and so does the automatic Optional bridge: a bridged property left unset reads as empty, so patch writes Optional.empty() rather than keeping the current value. The same MappingLaws patch overload law-checks it; the laws compare domain values only, so the bean needs no equals. An all-primitive bean projection, whose reads can never be null, keeps its lawful asLens().
This is not the REST PATCH contract, even when the bean is a PATCH request: an unset property never means "keep the current value". For that, extend UpdateSpec (Sparse PATCH).
patch only reads the bean, through its getters, but the Impl also carries build, which writes one, so a bean projection still needs one of the construction strategies above. A getter-only bean narrower than the domain is not a projection at all: nothing writes it, so it maps parse-only, and every domain component then needs a getter.
Accessors meant to stay out
Some beans leave an accessor unpaired on purpose. A response DTO reused as the PATCH body carries a server-assigned getId() the client must not change; a view computes getStatus() on the wire; a generated request has a setter the domain does not model. Pairing such an accessor is the wrong fix, and the bean is often not yours to edit, so the spec says the omission is deliberate with an abstract @Unmapped marker named after the accessor's property:
// A tenant record whose id the server assigns, and a PATCH body shared with the GET response: it
// reads the id and has no setter for it, so the client cannot change it.
record Tenant(String id, String name) {}
class TenantPatchBean {
private String name;
public String getId() {
return "t-9"; // whatever the body carries, the update never applies it
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
}
@GenerateMapping
interface TenantPatchMapping extends UpdateSpec<Tenant, TenantPatchBean> {
// getId() has no setter, so it is no property of the mapping, and 'id' names a component of
// Tenant: without the marker the mapping is refused, in case the accessor is a misspelt pair.
// The marker says the omission is deliberate. It withholds the refusal and nothing else: the
// update folds 'name' and never reads getId().
@Unmapped
String id();
}
TenantPatchBean tenantPatch = new TenantPatchBean();
tenantPatch.setName("Ada Lovelace");
Validated<NonEmptyList<FieldError>, Tenant> tenantPatched =
TenantPatchMappingImpl.INSTANCE.updateFrom(tenantPatch).apply(new Tenant("t-1", "Ada"));
// Valid(Tenant[id=t-1, name=Ada Lovelace]) - the t-9 the bean reads is never applied
The marker only withholds the refusal: the accessor was never a property, so the component it names stays unmapped, a wire narrower than the domain is still a projection, and nothing else about the generated Impl changes. It reaches a full mapping and a sparse UpdateSpec alike, and both refusals it answers: an accessor named after a domain component, and a setX setter a PATCH bean cannot read. The return type is not read, so it may restate the accessor's own type, and the marker is stubbed out by the Impl like a rename.
A marker the spec declares itself must name an accessor the bean leaves unpaired: one naming a property the mapping carries, or naming nothing at all, is refused as the misspelling it usually is. One inherited from a mix-in binds where it can and is otherwise inert, like every other inherited vocabulary member, so one mix-in serves specs whose wires differ.
One-directional beans
Some beans are only ever crossed one way. A generated client's response type, an immutable view built through its constructor, or a third-party result offers getters and nothing that writes it; an outbound request, or a write model behind a builder, is filled and never read back. Neither can support both directions, so the Impl carries the one it can and nothing for the other: the missing direction is absent, never a method that throws.
| The bean offers | It maps | The Impl carries |
|---|---|---|
| properties it can both read and write | both ways, as above | build, parse, asValidatedPrism() and the rest of its tier |
| getters, and no setters or builder that fill it | parse-only, unless every getter is a getter-only List | parse and asValidatedParse() |
| setters or a builder, and no getters | build-only | build and asValidatedBuild() |
// A vendor's read model: built once by its own client, then only ever read. It has getters and
// nothing that writes it, so the mapping is parse-only.
class CustomerView {
private final String name;
private final String email;
CustomerView(String name, String email) {
this.name = name;
this.email = email;
}
public String getName() {
return name;
}
public String getEmail() {
return email;
}
}
@GenerateMapping
interface CustomerViewMapping extends MappingSpec<Customer, CustomerView> {
default ValidatedPrism<String, EmailAddress> email() {
return EmailCodecs.EMAIL;
}
}
// An outbound request: filled and sent, never read back. It has setters and no getters, so the
// mapping is build-only.
class CustomerRequest {
private String name;
private String email;
public void setName(String name) {
this.name = name;
}
public void setEmail(String email) {
this.email = email;
}
String describe() {
return name + " <" + email + ">";
}
}
@GenerateMapping
interface CustomerRequestMapping extends MappingSpec<Customer, CustomerRequest> {
default ValidatedPrism<String, EmailAddress> email() {
return EmailCodecs.EMAIL;
}
}
// Parse-only: the Impl has parse and asValidatedParse(), and no build.
Validated<NonEmptyList<FieldError>, Customer> read =
CustomerViewMappingImpl.INSTANCE.parse(new CustomerView("Ada", "ada@corp.example"));
// Valid(Customer[name=Ada, email=EmailAddress[value=ada@corp.example]])
// Build-only: the Impl has build and asValidatedBuild(), and no parse.
CustomerRequest request =
CustomerRequestMappingImpl.INSTANCE.build(
new Customer("Ada", new EmailAddress("ada@corp.example")));
// new CustomerRequest(); setName("Ada"); setEmail("ada@corp.example")
The bean's shape decides, and a note says which way it was read and why, so an unintended reading does not go unnoticed: a bean meant to be built whose no-args constructor the generated Impl cannot reach reads parse-only, and the note says the constructor is out of reach. The two-way reading wins whenever any property allows it, so a bean is one-directional only when nothing at all crosses the other way. How a bean's direction is read covers the mixed cases, such as a bean that reads some names and writes others. One of them maps both ways: a bean whose every getter is a getter-only List, which build fills the JAXB way, through getX().addAll(...).
The rules follow from which direction is missing:
- Coverage belongs to the direction. A parse produces the domain, so every domain component needs a getter, and a getter no component names is ignored. A build produces the bean, so every writer needs a source, a domain component or a derived field, and a domain component the bean does not carry is not written. Neither is a projection, since nothing is written back.
- The rest of the vocabulary is unchanged. Renames, leaves, container lifting and the automatic
Optionalbridge work in whichever direction exists: a leaf parses on a parse-only bean and builds on a build-only one. - Nesting follows the direction. A one-directional mapping nests wherever only its direction is used, lifted through containers like any other: a parse-only spec inside a parse-only mapping, a sparse
UpdateSpecor a@GenerateMergesource, and a build-only spec inside a build-only mapping. A full mapping nests in all of them. Where the missing direction is needed, the failed lookup names the one-directional spec and what it lacks; sealed dispatch needs both directions of every subtype pair. - Law-check the surface it has.
MappingLawstakesasValidatedParse()with a parsing and a non-parsing wire, orasValidatedBuild()with a domain value (What Your Spec Generates):
MappingLaws.assertMappingLaws(
CustomerViewMappingImpl.INSTANCE.asValidatedParse(),
new CustomerView("Ada", "ada@example.org"), // parses
new CustomerView("Bob", "not-an-email")); // located failure
MappingLaws.assertMappingLaws(
CustomerRequestMappingImpl.INSTANCE.asValidatedBuild(),
new Customer("Ada", new EmailAddress("ada@example.org"))); // renders without failing
- Beans map with the full feature set: only the read/write mechanics differ, and the tiers stay honest (
asIsois withheld, and a projection takespatchrather thanasLens, where unset properties make reads fallible) - A bean crossed one way maps that way: a read model gets
parsealone and a write modelbuildalone, each nesting where its one direction is used
- Sparse PATCH: A bean as a PATCH request, where
nullmeans leave unchanged - Bean wires: The precise rules, from an unpaired accessor to a getter-only
List - What Your Spec Generates: Which methods each spec shape gets, and why a bean mapping withholds
asIso()
Previous: What Your Spec Generates Next: Sparse PATCH