Bean-Shaped Wires

Map getter/setter and builder classes as you map 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. If your wire is a bean, almost nothing changes: leaves, renames, container lifting, nesting and located errors work exactly as on a record. Three things differ, and all three come from one fact: a bean can exist with some properties never set. This page walks those three first. Mapping a generated client? Read the checklist for generated clients as well, and for a gRPC boundary, protobuf-java messages. A bean used as a PATCH request, where null means not sent, has a page of its own, Sparse PATCH.

What You'll Learn

  • Map a getter/setter or builder bean like a record, and predict what an unset property does
  • Predict which methods a bean's Impl carries, from the bean's shape
  • Map a protobuf-java message, and predict what an unset field reads
  • Map a protobuf oneof to a sealed type, and apply an update through its FieldMask

See Example Code

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 spec is the same interface as for a record. Only how the wire is read and written changes. build fills the bean through its setters or a builder, never a constructor that takes arguments, and parse reads it through its getters:

// A generated, mutable getter/setter DTO - generated, so not yours to annotate. 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"));
    ContactMappingImpl contactMapping = ContactMappingImpl.INSTANCE;

    ContactBean bean = contactMapping.build(ada); // new ContactBean(); setName; setEmail
    Validated<NonEmptyList<FieldError>, Customer> fromBean = contactMapping.parse(bean);
    // Valid(Customer[name=Ada, email=EmailAddress[value=ada@corp.example]])

    ContactBean noEmail = new ContactBean(); // a bean can exist with a property never set
    noEmail.setName("Bob");
    Validated<NonEmptyList<FieldError>, Customer> fromUnset = contactMapping.parse(noEmail);
    // Invalid(NonEmptyList[email: must not be null])

An unset property is an ordinary state of a bean, and parse reports it as any other null: a located must not be null, under the null rule. The processor recognises these shapes:

The bean offersbuild writes it withIt maps
a no-args constructor the Impl can call, and setX setters (Lombok @Data included)new, then each setterboth ways
a static builder() or newBuilder() whose build() returns it (a hand-written builder, or Lombok's @Builder or @SuperBuilder)the builder's setters, then build()both ways, reading the built type's getters
a getter-only List beside its setters, the JAXB waygetItems().addAll(...) for that list (when the getter is declared nullable, only if it answers a list)both ways
getters, and nothing that writes it, such as a view built through a constructor with argumentsnothingparse only
setters or a builder, and no gettersthe setters or the builderbuild only

As on a record, every property the bean both reads and writes needs a domain component or a derived field. The processor refuses the properties your domain lacks, naming each one, with has more components than. How a bean is read and written says how the processor pairs getters with writers.

Because a property can be unset, three things differ from a record wire:

  1. A reference property costs the pair asIso(). A record pair whose components all copy unchanged also gets asIso(), a two-way conversion that cannot fail. A bean's read of a reference property can meet an unset value, so a bean pair with one keeps build, parse and asValidatedPrism(), and not that. A bean whose properties are all primitive keeps asIso().
  2. An Optional bridges with no annotation. A domain Optional<T> maps to a nullable property T, where a record wire needs @OptionalBridge. build writes null for empty, and parse reads a null as empty: The automatic Optional bridge on a bean.
  3. A bean with fewer properties than your domain has no parse. With a reference property, the Impl offers a validated patch instead, which copies the bean onto a domain value you already hold: Bean projections.

Not checked for you: what the bean does with the null an empty Optional writes

build writes null for an empty Optional, replacing any field initialiser. No compiler sees what the bean then does with it:

  • A default applied to it reads back as present, whether a setter, a builder's build() or a getter applies it. A setter that swaps in "Untitled" turns every empty value into Optional[Untitled].
  • A writer that rejects it throws from build, as a setter that copies with List.copyOf(v) does, or a builder that calls requireNonNull. Guard the copy with v == null ? null : List.copyOf(v), or declare the component without the Optional.
  • A model that tells a sent null from an unset one sends it. An openapi-generator java client model keeps setX(null) in its JsonNullable companion as a sent null, and reads both back as null:
    // build writes the empty subtitle through setSubtitle(null), which the model sends as null,
    ListingModel built = ListingModelMappingImpl.INSTANCE.build(lamp);
    assertThat(json.writeValueAsString(built)).isEqualTo("{\"title\":\"Lamp\",\"subtitle\":null}");
    // where a fresh model leaves the property out.
    ListingModel fresh = new ListingModel();
    fresh.setTitle("Lamp");
    assertThat(json.writeValueAsString(fresh)).isEqualTo("{\"title\":\"Lamp\"}");

    // getSubtitle() answers null for an omitted property and for an explicit null alike.
    ListingModel omitted = json.readValue("{\"title\":\"Lamp\"}", ListingModel.class);
    ListingModel sentNull =
        json.readValue("{\"title\":\"Lamp\",\"subtitle\":null}", ListingModel.class);
    assertThatValidated(ListingModelMappingImpl.INSTANCE.parse(omitted)).hasValue(lamp);
    assertThatValidated(ListingModelMappingImpl.INSTANCE.parse(sentNull)).hasValue(lamp);

A law check from a domain sample with an empty Optional fails on the first two, whatever the bean's equals: MappingLaws.assertMappingLaws(mapping.asValidatedPrism(), sample). The check from two wire samples compares beans with equals, so a hand-written bean without one fails it on identity alone. A companion model fails it on a sample that leaves a nullable property unset, which comes back sent.

You can ship now

You can now map a getter/setter or builder bean like a record, and know what an unset property does to parse, to an Optional and to the methods the Impl offers. The rest of this page, beans crossed one way, projections, accessors kept out on purpose, protobuf-java messages and the checklist for generated clients, is for when you need it.

Checkpoint: which subtitle comes back?

Two generated beans for Listing both start the subtitle as "", in different places:

// A product listing whose subtitle is optional, and two generated beans for it.
record Listing(String title, Optional<String> subtitle) {}

class DraftListingBean {
  private String title;
  private @Nullable String subtitle = ""; // starts as "" in the field

  public String getTitle() {
    return title;
  }

  public void setTitle(String title) {
    this.title = title;
  }

  public @Nullable String getSubtitle() {
    return subtitle;
  }

  public void setSubtitle(@Nullable String subtitle) {
    this.subtitle = subtitle;
  }
}

class ListingBean {
  private String title;
  private @Nullable String subtitle;

  public String getTitle() {
    return title;
  }

  public void setTitle(String title) {
    this.title = title;
  }

  public String getSubtitle() {
    return subtitle == null ? "" : subtitle; // answers "" in the getter
  }

  public void setSubtitle(@Nullable String subtitle) { // the bridge writes null for empty
    this.subtitle = subtitle;
  }
}

@GenerateMapping
interface DraftListingMapping extends MappingSpec<Listing, DraftListingBean> {}

@GenerateMapping
interface ListingMapping extends MappingSpec<Listing, ListingBean> {} // subtitle bridges itself

Your code builds each bean from new Listing("Lamp", Optional.empty()), then parses it. What subtitle comes back from each?

  1. Optional.empty from both, since build wrote null into both
  2. Optional[], that is Optional.of(""), from both, since both start with ""
  3. Optional.empty from DraftListingBean, and Optional[] from ListingBean
  4. Optional[] from DraftListingBean, and Optional.empty from ListingBean

Answer and why

3. build writes setSubtitle(null) into both beans. That replaces DraftListingBean's field initialiser, so its subtitle reads back empty. parse reads through the getter, and ListingBean's getter answers "" for that null, so its subtitle comes back as Optional[]. A law check from a domain sample with an empty Optional fails on the second:

    Listing lamp = new Listing("Lamp", Optional.empty());

    // build writes setSubtitle(null) into both beans, replacing the field's "".
    DraftListingBean draft = DraftListingMappingImpl.INSTANCE.build(lamp);
    assertThatValidated(DraftListingMappingImpl.INSTANCE.parse(draft)).hasValue(lamp);

    // parse reads through the getter, which answers "" for that null.
    ListingBean listing = ListingMappingImpl.INSTANCE.build(lamp);
    assertThatValidated(ListingMappingImpl.INSTANCE.parse(listing))
        .hasValue(new Listing("Lamp", Optional.of("")));

    // A law check from a domain sample with an empty Optional fails on it:
    assertThatThrownBy(
            () ->
                MappingLaws.assertMappingLaws(ListingMappingImpl.INSTANCE.asValidatedPrism(), lamp))
        .isInstanceOf(AssertionError.class);

Where this lives: Bean-shaped wire targets.

Checkpoint: which methods does the Impl carry?

A payments SDK builds this CreditNoteView through its public constructor:

import org.higherkindedj.optics.annotations.GenerateMapping;
import org.higherkindedj.optics.annotations.MappingSpec;

record CreditNote(String number, String currency) {}

final class CreditNoteView {
  private final String number;
  private final String currency;

  public CreditNoteView(String number, String currency) {
    this.number = number;
    this.currency = currency;
  }

  public String getNumber() { return number; }

  public String getCurrency() { return currency; }
}

@GenerateMapping
interface CreditNoteViewMapping extends MappingSpec<CreditNote, CreditNoteView> {}

What does CreditNoteViewMappingImpl carry?

  1. build and parse: build can call the public constructor
  2. parse only
  3. build only
  4. Nothing: the processor refuses the spec

Answer and why

2. build writes a bean through setters or a builder, never a constructor that takes arguments, and CreditNoteView has neither. It has getters, so it maps parse-only, and the processor says so in a compiler note:

@GenerateMapping: 'CreditNoteView' maps parse-only: the generated Impl carries parse and
asValidatedParse(), and no build. 'CreditNoteView' has getters but no setX setters and no builder
that fills it, so nothing can write it. If 'CreditNoteView' should be built too, give it a no-args
constructor with setX setters matching its getters, or a builder.

Where this lives: Bean-shaped wire targets.


One-directional beans

Some beans offer only one direction. An immutable view built through its constructor, or a third-party result, has getters and nothing that writes it. An outbound request has setters or a builder, and no getters. What decides is the bean's shape, not how you use it: a generated client's model usually has getters and setters, so it maps both ways even when you only ever read it. The Impl carries the direction the bean allows, and nothing for the other: the missing direction is absent, never a method that throws.

flowchart TD
    accTitle: Which way a bean maps
    accDescr: A bean maps both ways when any getter shares its name with a writer. Otherwise a bean with only getters maps parse-only, one with only writers maps build-only, and one with both but no shared name, or with neither, is refused.
    P{"does any getter share its name<br/>with a setter or builder setter?"} -->|yes| B["both ways:<br/>build, parse, asValidatedPrism()"]
    P -->|no| G{"what does it offer?"}
    G -->|getters only| PO["parse only:<br/>parse, asValidatedParse()"]
    G -->|writers only| BO["build only:<br/>build, asValidatedBuild()"]
    G -->|"both, or neither"| X["refused"]

    classDef decision fill:#e5c890,stroke:#df8e1d,color:#232634
    classDef tier fill:#a6d189,stroke:#40a02b,color:#232634
    classDef error fill:#e78284,stroke:#d20f39,color:#232634
    class P,G decision
    class B,PO,BO tier
    class X error

In words: one name that is both read and written makes a bean two-way. A bean maps one way only when it offers nothing at all in the other direction. A getter-only List counts as written, the JAXB way, on a bean that also has a setter, or whose every getter is such a list.

// 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")

A compiler note says which way a one-directional bean was read, and why. Renames, leaves, container lifting and the automatic Optional bridge work in whichever direction exists. How a bean's direction is read covers coverage, nesting and the mixed cases. Law-check the surface the Impl has, asValidatedParse() with a parsing and a non-parsing wire, or asValidatedBuild() with a domain value:

    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

Bean projections

A bean with fewer properties than the domain has no parse, since it cannot produce a whole domain value. With a reference property, the Impl offers a validated patch(current, bean) instead, which copies the bean's properties onto a domain value you already hold, checking each one. A record projection that copies by identity gets a lens instead, since a record is constructed whole. A bean is constructed empty and filled by setters, so a property can be unset, and a lens has no way to refuse one. Here BadgeHolder is record BadgeHolder(String name, String department, int age), from What Your Spec Generates:

// A bean carrying one of BadgeHolder'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<BadgeHolder, TransferBean> {}


    BadgeHolder researcher = new BadgeHolder("Ada", "Research", 36);
    TransferBean transfer = new TransferBean();
    transfer.setDepartment("Platform");
    TransferMappingImpl transferMapping = TransferMappingImpl.INSTANCE;

    // The bean's property can be unset, so the projection validates: patch, never a lens.
    Validated<NonEmptyList<FieldError>, BadgeHolder> transferred =
        transferMapping.patch(researcher, transfer);
    // Valid(BadgeHolder[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>, BadgeHolder> unset =
        transferMapping.patch(researcher, new TransferBean());
    // Invalid(NonEmptyList[department: must not be null])

Every projected property is validated, and the unprojected components are read from the domain argument, so they survive. Leaves, nested specs, container lifting and the automatic bridge all apply: an unset bridged property reads as empty, so patch writes Optional.empty() rather than keeping the current value. The MappingLaws patch overload law-checks it, comparing domain values only, so the bean needs no equals. An all-primitive bean projection keeps its lens.

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). And patch only reads the bean, but the Impl also carries build, so a projection bean needs a way to be written. A getter-only bean is no projection: it maps parse-only, which needs a getter for every domain component, so a getter-only bean narrower than the domain is refused.


Accessors meant to stay out

Some beans leave an accessor unpaired on purpose: a getter with no setter, or a setter with no getter. You need to say so only when the processor refuses the accessor, which it does when the accessor is named after a domain component, as the likely misspelling. A response DTO reused as a PATCH body, from Sparse PATCH, carries a server-assigned getId() the client must not change. Declare the omission deliberate with an abstract @Unmapped marker named after the accessor's property. It is MapStruct's ignore = true, except that it names the wire's accessor, and only withholds the refusal:

// A marketplace merchant 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 Merchant(String id, String name) {}

class MerchantPatchBean {
  private String name;

  public String getId() {
    return "m-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 MerchantPatchMapping extends UpdateSpec<Merchant, MerchantPatchBean> {
  // getId() has no setter, so it is no property of the mapping, and 'id' names a component of
  // Merchant: 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();
}


    MerchantPatchBean merchantPatch = new MerchantPatchBean();
    merchantPatch.setName("Brightside Homeware");

    Validated<NonEmptyList<FieldError>, Merchant> merchantPatched =
        MerchantPatchMappingImpl.INSTANCE
            .updateFrom(merchantPatch)
            .apply(new Merchant("m-1", "Brightside"));
    // Valid(Merchant[id=m-1, name=Brightside Homeware]) - the m-9 the bean reads is never applied

A method that only starts like an accessor, such as getaway(), is a word, and the marker can name one the processor would refuse, on a bean crossed either way. The marker withholds the refusal, and nothing else: the component stays out of the mapping. On a MappingSpec that leaves the bean narrower than the domain, so the Impl becomes a projection, with build and patch and no parse. To keep parse, mark a getter read-only instead. What @Unmapped withholds has the precise rule.


Properties you only read

An OpenAPI readOnly property is sent in responses and never in requests. openapi-generator gives it a getter and no setter, and sets it through the constructor Jackson calls. You want parse to read it, and build must not send it. Declare an abstract @ReadOnly marker named after the property, as you would @Unmapped:

// The client model openapi-generator writes for the same merchant, whose schema marks the id
// readOnly: a response carries it, and a request never sends it, so it has a getter and no setter.
class MerchantModel {
  private final String id;
  private String name;

  public MerchantModel() {
    this(null);
  }

  MerchantModel(String id) { // stands in for the generated @JsonCreator constructor
    this.id = id;
  }

  public String getId() {
    return id;
  }

  public String getName() {
    return name;
  }

  public void setName(String name) {
    this.name = name;
  }
}

@GenerateMapping
interface MerchantModelMapping extends MappingSpec<Merchant, MerchantModel> {
  // parse reads getId() into Merchant.id, and build leaves it unwritten. The Impl then carries
  // parse and build as two halves, asValidatedParse() and asValidatedBuild(), and no
  // asValidatedPrism(): parse cannot read back an id build never wrote.
  @ReadOnly
  String id();
}


    MerchantModel fetched = new MerchantModel("m-1"); // as Jackson reads a GET response
    fetched.setName("Brightside");
    MerchantModelMappingImpl merchantMapping = MerchantModelMappingImpl.INSTANCE;

    Validated<NonEmptyList<FieldError>, Merchant> merchant = merchantMapping.parse(fetched);
    // Valid(Merchant[id=m-1, name=Brightside])

    MerchantModel sent = merchantMapping.build(new Merchant("m-1", "Brightside Homeware"));
    boolean idSent = sent.getId() != null; // build never writes a read-only property
    // false

parse cannot read back a bean that build wrote, because the id is missing, so the mapping is no ValidatedPrism. The Impl carries parse and build as two halves, asValidatedParse() and asValidatedBuild(), and no asValidatedPrism() or asIso(). Each half nests where a mapping uses that direction alone, as a one-directional bean does. Law-check each half on its own:

    MappingLaws.assertMappingLaws(
        MerchantModelMappingImpl.INSTANCE.asValidatedParse(),
        merchantModel("m-1", "Brightside"), // parses
        merchantModel(null, "Brightside")); // located failure: no id to read

    MappingLaws.assertMappingLaws(
        MerchantModelMappingImpl.INSTANCE.asValidatedBuild(),
        new Merchant("m-1", "Brightside")); // renders without failing

A generated model that holds the merchant needs no declaration for it. Its mapping builds and parses, so it nests each half in its own direction and takes two halves itself, and a note says so:

// openapi-generator's model for a storefront: an ordinary two-way bean, holding the merchant model.
class StorefrontModel {
  private String url;
  private MerchantModel merchant;

  public String getUrl() {
    return url;
  }

  public void setUrl(String url) {
    this.url = url;
  }

  public MerchantModel getMerchant() {
    return merchant;
  }

  public void setMerchant(MerchantModel merchant) {
    this.merchant = merchant;
  }
}

// It nests MerchantModelMapping where it builds and parses, so it takes two halves too: parse
// nests through asValidatedParse(), build through asValidatedBuild(), and a note says so.
@GenerateMapping
interface StorefrontModelMapping extends MappingSpec<Storefront, StorefrontModel> {}


    MappingLaws.assertMappingLaws(
        StorefrontModelMappingImpl.INSTANCE.asValidatedParse(),
        storefrontModel(merchantModel("m-1", "Brightside")), // parses
        storefrontModel(merchantModel(null, "Brightside"))); // located failure: merchant.id

    MappingLaws.assertMappingLaws(
        StorefrontModelMappingImpl.INSTANCE.asValidatedBuild(),
        new Storefront("https://brightside.example", new Merchant("m-1", "Brightside")));

A projection cannot nest either, since its write-back needs a whole prism: Nesting a mapping with two halves.

A component that converts takes the marker on its leaf. What @ReadOnly reads has the precise rule.


protobuf-java messages

A protobuf-java message is a builder bean, so a gRPC boundary maps as any bean does, with nothing to configure. protoc generates more accessors than a message has fields, such as getNameBytes() beside a string field, so the processor reads a message by its fields. It takes no protobuf dependency of its own, and maps the classes protoc generates wherever they come from: this build, as here, or a jar. Here is the order service's dispatch request, which protoc compiles in the same build:

// A dispatch request as the order service's gRPC API sends one.
syntax = "proto3";

package book.mapping.v1;

import "google/protobuf/field_mask.proto";

option java_package = "org.higherkindedj.example.book.mapping.proto";
option java_multiple_files = true;
option java_outer_classname = "DispatchProto";

message CustomerMessage {
  string name = 1;
  string email = 2;
}

enum Priority {
  PRIORITY_UNSPECIFIED = 0;
  PRIORITY_STANDARD = 1;
  PRIORITY_EXPRESS = 2;
}

message DispatchRequest {
  CustomerMessage customer = 1;
  repeated string skus = 2;
  optional string note = 3;
  Priority priority = 4;
  oneof destination {
    string locker = 5;
    string pickup_point = 6;
  }
}

// An update names the fields it changes in its mask.
message UpdateDispatchRequest {
  DispatchRequest dispatch = 1;
  google.protobuf.FieldMask update_mask = 2;
}

The spec needs nothing new. The customer nests through its own spec, a leaf converts the generated enum, and the oneof maps to a sealed type. The leaf sits in a mix-in, DispatchVocabulary, which the update shares:

// A dispatch as the order service keeps it. DispatchRequest and CustomerMessage are the messages
// protoc generates from dispatch.proto.
enum DispatchPriority {
  STANDARD,
  EXPRESS
}

// The oneof 'destination': a record named after each member.
sealed interface Destination {
  record Locker(String id) implements Destination {}

  record PickupPoint(String code) implements Destination {}
}

record Dispatch(
    Customer customer,
    List<String> skus,
    Optional<String> note,
    DispatchPriority priority,
    Optional<Destination> destination) {}

@GenerateMapping
interface CustomerMessageMapping extends MappingSpec<Customer, CustomerMessage> {
  default ValidatedPrism<String, EmailAddress> email() {
    return EmailCodecs.EMAIL;
  }
}

// The customer nests through CustomerMessageMapping, the skus copy as a List, the note reads as
// empty when unset, and so does the oneof, whose set member becomes its record.
@GenerateMapping
interface DispatchMapping extends MappingSpec<Dispatch, DispatchRequest>, DispatchVocabulary {}

// The leaves DispatchMapping shares with DispatchPatch, an update over the same pair.
interface DispatchVocabulary {
  // The priority converts through this leaf, which refuses the unset PRIORITY_UNSPECIFIED and
  // the UNRECOGNIZED an unknown number reads as.
  default ValidatedPrism<Priority, DispatchPriority> priority() {
    return ValidatedPrism.of(
        wire ->
            switch (wire) {
              case PRIORITY_STANDARD -> Validated.validNel(DispatchPriority.STANDARD);
              case PRIORITY_EXPRESS -> Validated.validNel(DispatchPriority.EXPRESS);
              case PRIORITY_UNSPECIFIED, UNRECOGNIZED ->
                  Validated.invalidNel(FieldError.of("not a priority: " + wire));
            },
        domain ->
            switch (domain) {
              case STANDARD -> Priority.PRIORITY_STANDARD;
              case EXPRESS -> Priority.PRIORITY_EXPRESS;
            });
  }
}


    DispatchRequest dispatchRequest =
        DispatchRequest.newBuilder()
            .setCustomer(CustomerMessage.newBuilder().setName("Ada").setEmail("ada@corp.example"))
            .addSkus("SKU-1")
            .setPriority(Priority.PRIORITY_EXPRESS)
            .setLocker("LK-4")
            .build(); // no note: hasNote() is false
    DispatchMappingImpl dispatchMapping = DispatchMappingImpl.INSTANCE;

    Validated<NonEmptyList<FieldError>, Dispatch> dispatch = dispatchMapping.parse(dispatchRequest);
    // Valid(Dispatch[customer=Customer[name=Ada, email=EmailAddress[value=ada@corp.example]],
    // skus=[SKU-1], note=Optional.empty, priority=EXPRESS,
    // destination=Optional[Locker[id=LK-4]]])

    // The customer is a message field, which tracks whether it is set: unset, it reads as null.
    Validated<NonEmptyList<FieldError>, Dispatch> noCustomer =
        dispatchMapping.parse(dispatchRequest.toBuilder().clearCustomer().build());
    // Invalid(NonEmptyList[customer: must not be null])

    // An empty Optional leaves the field unset, so the message built has no note either.
    Customer grace = new Customer("Grace", new EmailAddress("grace@corp.example"));
    DispatchRequest built =
        dispatchMapping.build(
            new Dispatch(
                grace,
                List.of("SKU-2"),
                Optional.empty(),
                DispatchPriority.STANDARD,
                Optional.of(new Destination.PickupPoint("PP-9"))));
    boolean noteSent = built.hasNote();
    // false

A repeated field maps to a List and a map field to a Map, and build writes each whole. What an unset field reads depends on whether protobuf tracks it, which it shows by generating hasX():

The fieldUnset, parse reads it asA domain Optional over it
a message field, a field declared optional, a oneof member, or a singular proto2 fieldnull, so a plain component reports must not be nullempty, and build leaves the field unset
a proto3 scalar declared without optionalits default: "", 0, false or the first enum constantrefused, since its default would read back as present
a repeated or map fieldan empty List or Maprefused, as for a scalar

A oneof holds one member at a time, which a sealed type says too. A domain component named after the oneof maps it whole, and each variant is a record named after one member: Destination.Locker pairs with locker, and Destination.PickupPoint with pickup_point. A member holding a message becomes its variant through a spec for the pair, and a scalar member fills the variant's one component. As an Optional, the component reads a oneof with no member set as empty. You can instead map each member to an Optional of its own, as DispatchRecord does in the warning that follows. How a protobuf-java message is read and how a oneof maps have the precise rules.

Not checked for you: what a message cannot hold

The processor cannot see these three, and each shows only at run time:

  • An enum number the build does not know. A newer client can send a number your generated enum lacks, which its getter reads as UNRECOGNIZED. A component keeping the generated enum parses it, and build then throws. Convert the enum through a leaf that refuses UNRECOGNIZED, as priority() does.
  • Two members of one oneof. A domain value holding both a locker and a pickupPoint, as two Optional components, builds a message holding only the last one written. Map the oneof to a sealed type, as Dispatch does, so the domain holds one.
  • An unset proto2 required field. An empty Optional leaves it unset, and the builder's build() then throws UninitializedMessageException. Map a required field to a component without the Optional.
// The same request, its priority kept as the generated enum itself: no leaf converts it.
record DispatchRecord(
    Customer customer,
    List<String> skus,
    Optional<String> note,
    Priority priority,
    Optional<String> locker,
    Optional<String> pickupPoint) {}

@GenerateMapping
interface DispatchRecordMapping extends MappingSpec<DispatchRecord, DispatchRequest> {}


    DispatchRequest fromNewerClient =
        DispatchRequest.newBuilder()
            .setCustomer(CustomerMessage.newBuilder().setName("Ada").setEmail("ada@corp.example"))
            .setPriorityValue(3) // a priority added to the .proto after this build
            .build();

    // Kept as the generated enum, it parses as UNRECOGNIZED, which no builder can write back.
    DispatchRecord kept = DispatchRecordMappingImpl.INSTANCE.parse(fromNewerClient).get();
    assertThat(kept.priority()).isEqualTo(Priority.UNRECOGNIZED);
    assertThatThrownBy(() -> DispatchRecordMappingImpl.INSTANCE.build(kept))
        .isInstanceOf(IllegalArgumentException.class)
        .hasMessage("Can't get the number of an unknown enum value.");

    // Converted through a leaf, it is refused where it is read.
    assertThatValidated(DispatchMappingImpl.INSTANCE.parse(fromNewerClient))
        .isInvalid()
        .hasFieldErrors("priority: not a priority: UNRECOGNIZED");

    DispatchRecord both =
        new DispatchRecord(
            ada,
            List.of("SKU-1"),
            Optional.empty(),
            Priority.PRIORITY_STANDARD,
            Optional.of("LK-4"), // a locker
            Optional.of("PP-9")); // and a pickup point: this record allows both

    DispatchRequest built = DispatchRecordMappingImpl.INSTANCE.build(both);
    assertThat(built.getDestinationCase()).isEqualTo(DispatchRequest.DestinationCase.PICKUP_POINT);
    assertThat(DispatchRecordMappingImpl.INSTANCE.parse(built).get().locker()).isEmpty();

A PATCH through its FieldMask

A message reads a value for most fields it has not set, so an unset field cannot mean leave unchanged. A gRPC update request names the fields it changes in a FieldMask instead. Extend UpdateSpec over the message, and the Impl's updateFrom(message, mask) returns the edits the mask asks for. DispatchPatch shares its leaves with DispatchMapping through the DispatchVocabulary mix-in:

@GenerateMapping
interface DispatchPatch extends UpdateSpec<Dispatch, DispatchRequest>, DispatchVocabulary {}


    Dispatch stored =
        new Dispatch(
            new Customer("Lin", new EmailAddress("lin@corp.example")),
            List.of("SKU-3"),
            Optional.empty(),
            DispatchPriority.STANDARD,
            Optional.of(new Destination.Locker("LK-7")));
    UpdateDispatchRequest update =
        UpdateDispatchRequest.newBuilder()
            .setDispatch(
                DispatchRequest.newBuilder().setNote("leave at the door").setPickupPoint("PP-2"))
            .setUpdateMask(FieldMask.newBuilder().addPaths("note").addPaths("pickup_point"))
            .build();

    Validated<NonEmptyList<FieldError>, Dispatch> updated =
        DispatchPatchImpl.INSTANCE.updateFrom(update.getDispatch(), maskOf(update)).apply(stored);
    // Valid(Dispatch[customer=Customer[name=Lin, email=EmailAddress[value=lin@corp.example]],
    // skus=[SKU-3], note=Optional[leave at the door], priority=STANDARD,
    // destination=Optional[PickupPoint[code=PP-2]]])

Each field the mask names parses as parse would read it, and replaces the stored value whole, so the stored locker gave way to the pickup point. A field the mask names and the message leaves unset reads as parse reads it: the note would clear, and the customer would fail. A path is the field's name in the .proto file, or * for every field. A path into a nested message, such as customer.name, is not supported yet, and fails located at the path.

An empty mask names no field, so the update changes nothing. A request that omits its mask asks for every field its message sets, as AIP-134 has it, and maskOf builds that mask on the full runtime:

  // A request that omits its mask asks for every field its message sets. An empty mask names none.
  static FieldMask maskOf(UpdateDispatchRequest update) {
    return update.hasUpdateMask()
        ? update.getUpdateMask()
        : FieldMask.newBuilder()
            .addAllPaths(
                update.getDispatch().getAllFields().keySet().stream()
                    .map(Descriptors.FieldDescriptor::getName)
                    .toList())
            .build();
  }

A protobuf-java message updates through its FieldMask has the precise rules.


Generated clients: a checklist

Beans are often generated from a schema, and generators have habits. Check these where a generated bean meets the mapper:

The generated shapeWhat happensInstead
an openapi-generator model, with getters, setters and a no-args constructorit maps both ways, even as a response you only read, so the processor refuses a property your domain lacksdeclare a derived field for it, which build fills and parse ignores
a readOnly property, with a getter and no setterit is refused as a likely misspelling, since build could never write itdeclare it @ReadOnly: parse reads it and build leaves it out, so the Impl has two halves and no asValidatedPrism(), as does a model that holds it
strictly typed properties: enums, OffsetDateTime, UUIDJackson has already converted them before parse runsmap each to your own type with a leaf over the generated type
an openapi-generator java client model with its default openApiNullable=truethe processor leaves out the getX_JsonNullable() and setX_JsonNullable(...) pair beside each nullable property, and maps it through getX() and setX(...)nothing: An openapi-generator JsonNullable companion says what an unset property reads. Clearing one through a PATCH is not supported yet
an openapi-generator spring model with its default openApiNullable=trueeach nullable property is a JsonNullable<T> itselfmap it through a leaf; a PATCH bean reads its three states, as A JsonNullable property keeps, clears or sets says
a PATCH request bean with default: values or container defaultsthe generator renders them as initialisers, which read as sentgive the PATCH request its own schema: A PATCH getter must answer null until set
a Lombok classthe processor sees its accessors only once Lombok has runlist Lombok's annotationProcessor before hkj-processor; the HKJ Gradle plugin adds its own after your dependencies block (Lombok)
Lombok's @Singular on a collectionit maps: build writes the collection whole and leaves the singular adder alone. The collection is never null, so it cannot carry an absent Optional or a PATCH's absencefor an Optional component, declare it a List or drop @Singular; on a PATCH request, drop @Singular: A Lombok @Singular collection
a protobuf-java messageit maps both ways by its fields, and protoc's other accessors, such as getXBytes(), stay out. An unset field with hasX() reads as null, and an update follows its FieldMasknothing, unless an Optional faces a proto3 scalar: declare that field optional in the .proto (protobuf-java messages)
a bean another annotation processor generatesthe mapping waits for the type to exist, with nothing to configurenothing: Mapping over types other processors generate

Key Takeaways

  • A bean maps like a record: leaves, renames, nesting and located errors are unchanged, and every property it reads and writes needs a source
  • Three things follow from an unset property: a reference property costs asIso(), an Optional bridges with no annotation, and a smaller bean has no parse
  • What the bean does with a null is not checked for you: a default reads back as present, and a writer that rejects it throws, so law-check from a domain sample with an empty Optional
  • A bean's shape decides its direction: a read model gets parse alone, a write model build alone, and a generated model with getters and setters maps both ways
  • A protobuf-java message maps by its fields: a field with hasX() reads as null when unset, a oneof maps to a sealed type, and an update follows its FieldMask

See Also

  • Sparse PATCH: A bean as a PATCH request, where null means leave unchanged
  • Bean wires: The precise rules, from how a bean is written to a getter-only List
  • What Your Spec Generates: Which methods each spec shape gets, and why a reference property withholds asIso()

Previous: What Your Spec Generates Next: Sparse PATCH