Merge and Error Envelopes

The forward-only sibling that assembles one target from several sources, and the generator that types your error context.

Two more generators complete the family. @GenerateMerge covers the assembly a boundary often needs just after parsing: one domain value built from several inputs. @GenerateErrorEnvelope covers the other end of the boundary: the typed domain error a fallible mapping produces, without the copy-pasted envelope fields and the untyped Map<String, Object> context.

What You'll Learn

  • Predict what a merge fills, and what a merge with a plain return does with a null
  • Keep an error envelope's context a record whose every component accepts null

See Example Code

The code on this page is MergeBook.java, OrderErrorBook.java, EnvelopeContextBook.java and MergeBookTest.java - the page includes them directly, so they are compiled and run by the build.

Merging several sources: @GenerateMerge

A merge is declared entirely by a method's signature: two or more record sources in, one record target out. It is a MapStruct method with several source parameters, except that each target component fills from the one source that names it, and there is no rename. The processor generates no inverse, since a merge cannot be undone:

// Customer is the chapter's own, from Record Mapping Basics.

record Account(String iban, int balance) {}

record Settings(boolean darkMode) {}

record Dashboard(String name, String iban, boolean darkMode) {}

@GenerateMerge
interface DashboardAssembly {
  Dashboard assemble(Customer customer, Account account, Settings settings);
}


    Dashboard dashboard =
        DashboardAssemblyImpl.INSTANCE.assemble(
            new Customer("Ada", new EmailAddress("ada@corp.example")),
            new Account("GB29-XXXX", 4200),
            new Settings(true));
    // Dashboard[name=Ada, iban=GB29-XXXX, darkMode=true]
flowchart LR
    accTitle: How a merge fills
    accDescr: Dashboard's name fills from Customer's name, its iban from Account's iban, and its darkMode from Settings' darkMode. Customer's email and Account's balance are not read.
    subgraph S["the sources"]
        CN["Customer.name"]
        CE["Customer.email (not read)"]
        AI["Account.iban"]
        AB["Account.balance (not read)"]
        SD["Settings.darkMode"]
    end
    subgraph T["Dashboard"]
        TN["name"]
        TI["iban"]
        TD["darkMode"]
    end
    CN --> TN
    AI --> TI
    SD --> TD

    classDef wire fill:#8caaee,stroke:#1e66f5,color:#232634
    classDef domain fill:#a6d189,stroke:#40a02b,color:#232634
    class CN,CE,AI,AB,SD wire
    class TN,TI,TD domain

In words: each of Dashboard's components comes from the one source that names it, and a source component the target lacks is not read.

A fill copies when the types match, and a same-typed container crosses as a copy. Otherwise it converts through a leaf, a default method on the merge interface named after the target component. Or it parses through a @GenerateMapping spec whose wire is the source component and whose domain is the target component. Here a support desk ticket takes its agent's department and parses its customer through CustomerMappingImpl, and a failure locates as a dotted path:

// A support desk ticket: the department of the agent who opens it (an Employee, as on What Your
// Spec Generates), and the customer a form describes.
record TicketForm(CustomerDto customer) {} // the wire side

record TicketCard(String department, Customer customer) {} // the domain side

@GenerateMerge
interface TicketCardAssembly {
  // TicketCard.department fills from the agent's; TicketCard.customer from TicketForm.customer,
  // through CustomerMapping, which can fail.
  Validated<NonEmptyList<FieldError>, TicketCard> assemble(Employee agent, TicketForm form);
}

    Validated<NonEmptyList<FieldError>, TicketCard> card =
        TicketCardAssemblyImpl.INSTANCE.assemble(
            new Employee("Grace", "Support", 41), new TicketForm(new CustomerDto("Bob", "nope")));
    // Invalid(NonEmptyList[customer.email: not an email address])

The return type must tell the truth. A fill that can fail demands the Validated return, and a merge whose every fill is a copy must declare the plain target. How a merge fills has the rest, including why an outbound view cannot fill through a spec's build yet.

Not checked for you: a merge with a plain return passes nulls through

A merge that returns Validated checks what it reads, as parse does under the null rule. A merge with a plain return checks nothing: a null flows into the target as it is, and whatever the target's constructor throws propagates. To have a merge check, give a component a leaf that can fail, which makes the merge fallible and its return Validated (Nulls and guards in a merge).


Generating error envelopes: @GenerateErrorEnvelope

A sealed error hierarchy tends to re-declare the same envelope on every variant, with an untyped context:

import java.time.Instant;
import java.util.List;
import java.util.Map;

sealed interface OrderError {
  record OutOfStock(
          List<String> products,
          String code,
          String message,
          Instant timestamp,
          Map<String, Object> context)
      implements OrderError {}

  record PaymentDeclined(
          String card, String code, String message, Instant timestamp, Map<String, Object> context)
      implements OrderError {}
}

@GenerateErrorEnvelope supplies the envelope and types the context, so each variant declares only its own components, plus one ErrorEnvelope<C>. It plays the part Spring's ProblemDetail plays for an HTTP response, but on the domain error, and its extra properties are a typed record rather than an untyped map:

// The context is records-as-schema: nullable components, an all-absent default.
record OrderErrorContext(@Nullable OrderId orderId, @Nullable TraceId traceId) {}

@GenerateErrorEnvelope
sealed interface OrderError {
  ErrorEnvelope<OrderErrorContext> envelope(); // declared once

  // A one-line default so the generated editContext reads as an instance method.
  default OrderError editContext(UnaryOperator<OrderErrors.ContextBuilder> edit) {
    return OrderErrors.editContext(this, edit);
  }

  record OutOfStock(List<ProductId> products, ErrorEnvelope<OrderErrorContext> envelope)
      implements OrderError {}

  record PaymentDeclined(CardRef card, ErrorEnvelope<OrderErrorContext> envelope)
      implements OrderError {}
}

This typed context is data attached to an error value, unrelated to the ErrorContext effect type.

For OrderError the processor generates a companion named OrderErrors with three pieces:

  • A factory per variant. Its code is the UPPER_SNAKE variant name and its message the humanised name, the same for every error of that variant. A new error starts with the all-absent context, every component null. Each factory has an overload taking a TimeSource, which the example uses for a fixed clock.
  • A fluent context() builder over the context record's components, for building a context in a factory of your own.
  • An editContext(error, edit) method that returns a copy of the error with its context changed, starting from its current values.

Add a one-line default, as OrderError does, and construction plus enrichment reads as you would write it by hand:

    TimeSource clock = TimeSource.fixed(Instant.parse("2026-07-28T12:00:00Z"));
    OrderError error =
        OrderErrors.outOfStock(clock, products) // typed factory
            .editContext(ctx -> ctx.orderId(orderId).traceId(traceId)); // typed, not map.put
    // ErrorEnvelope[code=OUT_OF_STOCK, message=Out of stock, timestamp=2026-07-28T12:00:00Z,
    //   context=OrderErrorContext[orderId=OrderId[value=ORD-1], traceId=TraceId[value=trace-1]]]

For a message that varies per error, write a factory of your own that calls ErrorEnvelope.of(time, code, message, context), or change one error's with envelope().withMessage(...). withContext(D) is on the envelope too: it replaces the context, perhaps with another type, so the variant must be rebuilt around the new envelope. editContext is on the error, and enriches the context it has.

The processor finds the context type from the ErrorEnvelope component's type argument, and refuses variants that disagree on it. Error envelope rules lists the shapes it refuses.

Not checked for you: every context component must accept null

The companion builds an all-absent context on its first use, with every component null. A context whose compact constructor rejects null compiles, then fails that first use with an ExceptionInInitializerError, and every use after with NoClassDefFoundError, until the JVM restarts:

record RefundErrorContext(TraceId traceId) {
  RefundErrorContext {
    Objects.requireNonNull(traceId, "traceId");
  }
}

@GenerateErrorEnvelope
sealed interface RefundError {
  ErrorEnvelope<RefundErrorContext> envelope();

  record RefundWindowClosed(String orderId, ErrorEnvelope<RefundErrorContext> envelope)
      implements RefundError {}
}

Let every component accept null, and check a value in a factory of your own that takes it.

You can ship now

You can now assemble a domain value from several sources with a return type that tells the truth, and give a sealed error hierarchy one typed envelope. The rest of this page, fine-grained or coarse variants, is for when you need it.

Checkpoint: what does a plain-return merge do with a null?

DashboardAssembly, from the start of the page, returns the plain Dashboard. What does it return for the same Account and Settings and a Customer loaded from a legacy row whose name is null, new Customer(null, new EmailAddress("ada@corp.example"))?

  1. It throws a NullPointerException
  2. Invalid(NonEmptyList[name: must not be null])
  3. Dashboard[name=null, iban=GB29-XXXX, darkMode=true]
  4. Nothing: the processor refuses a merge that can read a null

Answer and why

3. A merge with a plain return checks nothing, so the null flows into the Dashboard as it is, with no error:

    Dashboard dashboard =
        DashboardAssemblyImpl.INSTANCE.assemble(
            new Customer(null, new EmailAddress("ada@corp.example")),
            new Account("GB29-XXXX", 4200),
            new Settings(true));

    assertThat(dashboard).isEqualTo(new Dashboard(null, "GB29-XXXX", true)); // no check, no error

A leaf that can fail on name would make the merge fallible, its return Validated, and the null a located must not be null.

Where this lives: Merging several sources.

Checkpoint: does this context break its companion?

A chargeback error's context checks its trace id too:

record ChargebackErrorContext(@Nullable TraceId traceId) {
  ChargebackErrorContext {
    if (traceId != null && traceId.value().isBlank()) {
      throw new IllegalArgumentException("traceId is blank");
    }
  }
}

@GenerateErrorEnvelope
sealed interface ChargebackError {
  ErrorEnvelope<ChargebackErrorContext> envelope();

  record ChargebackOpened(String orderId, ErrorEnvelope<ChargebackErrorContext> envelope)
      implements ChargebackError {}
}

What happens on the first call to ChargebackErrors.chargebackOpened("ORD-1")?

  1. ExceptionInInitializerError, as for RefundErrors
  2. It returns an error whose context holds a null trace id
  3. The processor refuses the compact constructor at compile time
  4. It returns an error, and a later editContext call throws

Answer and why

2. This constructor rejects only a present, blank trace id, so it accepts the all-absent context the companion builds on first use. RefundErrorContext rejects null itself, which is what breaks its companion:

    assertThat(ChargebackErrors.chargebackOpened("ORD-1").envelope().context())
        .isEqualTo(new ChargebackErrorContext(null)); // accepts the all-absent context

    assertThatThrownBy(() -> RefundErrors.refundWindowClosed("ORD-1")) // the first use
        .isInstanceOf(ExceptionInInitializerError.class)
        .cause()
        .hasMessage("traceId");
    assertThatThrownBy(() -> RefundErrors.refundWindowClosed("ORD-2")) // and every use after
        .isInstanceOf(NoClassDefFoundError.class);

Where this lives: Generating error envelopes.


Fine-grained or coarse variants?

The design choice is about the hierarchy, not the annotation:

ShapeExampleHow its errors are built
Fine-grained: one variant per failure mode, each with its own typed fieldsthe market example's MarketError, used in Building the market pipelinethe generated factory, wherever the code and message are the variant's name; EnrichmentFailed, whose message is the lookup detail, keeps a factory of its own
Coarse: one variant for a category of codesthe Order Workflow example's OrderError (not the one on this page), whose CustomerError covers CUSTOMER_NOT_FOUND and CUSTOMER_SUSPENDEDa factory of your own per code, calling ErrorEnvelope.of(...) with the generated context() builder; the generated customerError(...) stays public, with the code CUSTOMER_ERROR

Either way, the repeated envelope and the untyped Map<String, Object> are gone. Reach for fine-grained variants when each failure mode is distinct, and group them when a boundary treats a whole category the same way, as a downstream switch presenting failures by category does. Give a factory of your own a TimeSource parameter, so tests can fix the clock.


Key Takeaways

  • A merge is a method signature: each target component fills from the one source that names it, and the return type follows the fills
  • A merge with a plain return checks nothing: a null flows through, so a merge that must check needs a leaf that can fail
  • @GenerateErrorEnvelope retires the copy-pasted envelope: one ErrorEnvelope<C> component, generated factories, and a typed context record whose every component accepts null

See Also


Previous: Generic Specs Next: Injecting, Testing, and Diagnostics