Mapper at a Glance

What it generates, what it costs, and the decisions to know before adopting it.

@GenerateMapping turns one interface you own into a mapper the compiler writes. Outbound, a build that cannot fail. Inbound, a parse that reports every bad field at once, each located by a dotted path. For the partial cases, a write-back. It runs at compile time and produces plain Java, so there is no reflection, no registry, and nothing to configure at startup.

This page is for judging the fit. If you would rather see it working first, the Quickstart is five steps to a real 422.


Install

plugins {
    id("io.github.higher-kinded-j.hkj") version "LATEST_VERSION"
}

The plugin wires hkj-core, the annotation processor, -parameters and the preview flags. The build runs on Java 25 today, with preview features enabled: javac accepts --enable-preview only for the release it is running on, so a later JDK waits on the library moving to it rather than on a version bump. Hand-rolled Gradle and Maven builds are in Manual setup, which also covers Lombok (list it before hkj-processor) and types another processor generates. For HTTP responses, add hkj-spring-boot-starter.


What it writes

A lossless pair, declared as an empty interface:

@GenerateMapping
interface PersonMapping extends MappingSpec<Person, PersonDto> {}

The processor writes what follows. It is a golden file in the processor's own tests, pinned byte for byte, so it is exactly what your build produces:

PersonMappingImpl.java, as generated

// Generated by hkj-processor. Do not edit.
package com.example.lossless;

import java.lang.String;
import java.util.Objects;
import java.util.function.Function;
import java.util.function.Supplier;
import org.higherkindedj.hkt.nonemptylist.NonEmptyList;
import org.higherkindedj.hkt.validated.FieldError;
import org.higherkindedj.hkt.validated.Validated;
import org.higherkindedj.optics.Iso;
import org.higherkindedj.optics.annotations.Generated;
import org.higherkindedj.optics.validated.ValidatedPrism;

/**
 * Generated bidirectional mapping for {@link PersonMapping}: total {@code build} and accumulating, located {@code parse}.
 */
@Generated
public final class PersonMappingImpl implements PersonMapping {
  public static final PersonMappingImpl INSTANCE = new PersonMappingImpl();

  private final ValidatedPrism<PersonDto, Person> hkj$adapter = ValidatedPrism.of(this::parse, this::build);

  private PersonMappingImpl() {
  }

  public PersonDto build(Person domain) {
    Objects.requireNonNull(domain, "domain must not be null");
    return new PersonDto(domain.name(), domain.age());
  }

  public Validated<NonEmptyList<FieldError>, Person> parse(PersonDto wire) {
    Objects.requireNonNull(wire, "wire must not be null");
    return hkj$construct(
        Validated.fields()
            .field("name", hkj$ifPresent(wire.name(), Validated::validNel))
            .field("age", Validated.validNel(wire.age()))
            .apply((name, age) -> () -> new Person(name, age)),
        "not a valid Person");
  }

  /**
   * This mapping as a {@link ValidatedPrism} leaf, so other mappings can nest it directly or lift it through containers.
   */
  public ValidatedPrism<PersonDto, Person> asValidatedPrism() {
    return hkj$adapter;
  }

  /**
   * Guards a nullable read: a null becomes a located {@code FieldError}, otherwise the value is parsed.
   */
  private static <S, A> Validated<NonEmptyList<FieldError>, A> hkj$ifPresent(S value,
      Function<? super S, Validated<NonEmptyList<FieldError>, A>> parse) {
    return value == null ? Validated.invalidNel(FieldError.of("must not be null")) : parse.apply(value);
  }

  /**
   * Runs the canonical constructor over the validated components: an exception it throws is the record's
   * invariant refusing them, reported at the record's path with its message, or with {@code
   * fallbackMessage} when it has none.
   */
  private static <T> Validated<NonEmptyList<FieldError>, T> hkj$construct(
      Validated<NonEmptyList<FieldError>, Supplier<T>> assembled, String fallbackMessage) {
    return assembled.flatMap(
        constructor -> {
          try {
            return Validated.validNel(constructor.get());
          } catch (RuntimeException refused) {
            String message = refused.getMessage();
            return Validated.invalidNel(FieldError.of(message == null || message.isBlank() ? fallbackMessage : message));
          }
        });
  }

  /**
   * The lossless mapping as an {@link Iso}; emitted only when no fallible leaf and no derived field exists, so the round trip is total (truthful types).
   */
  public Iso<Person, PersonDto> asIso() {
    return Iso.of(this::build, wire -> new Person(wire.name(), wire.age()));
  }
}

Plain static code: no reflection, no proxies, nothing to warm up, and a stack trace that lands in a file you can open. build copies; parse assembles the record through a fields() ladder that names each component, which is where the located errors come from; asValidatedPrism() is how one mapping nests inside another.


Decisions to know before adopting

The decisionWhy it is that wayWhat you do about it
A conversion is declared per component, never inferred from the typesAn implicit String to UUID conversion is a guess about a boundary the library cannot seeDeclare a leaf, and share the common ones through one mix-in vocabulary
Error paths use domain component namesPaths stay stable when the wire is renamedWhere a client needs wire names, map them back through the rename, or read the structured segments
A wire null is a located error unless you say otherwiseOn most wires a null really is a defect@OptionalBridge per component; automatic on bean wires
The domain must be a recordparse builds through the canonical constructorKeep entities at the far side of the boundary, and map to them from the record
The annotation goes on your spec interface, never on the mapped typesRecords, sealed hierarchies and beans from a compiled library cannot be editedDeclare the spec in your own code: @GenerateMapping interface VendorOrderMapping extends MappingSpec<VendorOrder, OrderDto> {}
Sparse PATCH is opt-in, bean-only and wrapper-typedA primitive can never be absent, and a record component is always presentUpdateSpec with Integer, Boolean and friends
A PATCH replaces a nested object wholesaleDeep merge is out of scopePatch the nested record through its own spec
A spec-carrying module on the module path neither writes nor reads the cross-module indexThe index is one package, and a package belongs to one moduleDelegate with a leaf calling the other Impl, or pass -Ahkj.mapping.index=false
The generated Impl is bound in the caller, never as a constant on the specClass initialisation can leave that constant null, intermittently; the processor warns at one typed as the ImplXImpl.INSTANCE at the call site, or an injected surface: why

Everything the processor refuses says so at compile time, with what is wrong, why it matters and the code to write. The complete set is in Rules and Limits, and the messages you are most likely to meet are in Compiler Messages.


Which wires map

Wire shapebuildparseProjection write-backupdateFrom
Record✅✅asLens(), or patch where a field validates❌ (records cannot express absence)
Getter/setter bean✅✅patch (any reference property), asLens() if all primitive✅
Builder bean (builder()/newBuilder())✅✅as above✅
Getter-only bean (nothing writes it)❌✅❌❌
Write-only bean (setters or builder only)✅❌❌❌
A two-way bean's @ReadOnly property (a getter, no setter)✅ (left out)✅ (read)❌❌
JAXB getter-only List✅ (through addAll)✅✅❌ (it can never read null)
Sealed hierarchy against sealed hierarchy✅✅❌❌
Generic record (Page<T>)✅✅✅❌ (record-to-record only)
JsonNullable property (a _JsonNullable companion is left out)through a leafthrough a leafthrough a leaf✅ (omitted, null or a value)

A lossless pair also earns asIso(); a bean pair with a reference property does not, because an unset property is an ordinary state. What Your Spec Generates explains which surface each spec gets, and why.


What it costs

  • Generated at compile time: the Impl is written once per build.
  • No reflection and no startup scan. A concrete Impl is a singleton that keeps each leaf after its first use; an element-mapped one carries its leaf prisms and is built by of(...).
  • One Validated per field read on the parse side, which is the price of accumulating errors rather than throwing on the first.
  • Lines of spec per field: counted field by field, against MapStruct and Bean Validation.
  • No component ceiling: a flat 30-field wire maps like a narrow one.

What you write, field by field

The project's mapping benchmark maps two pairs, a nested order and a flat record, four ways, so each row is counted from code that compiles. The generated mapper's specs, a MapStruct mapper and the same wire annotated for Bean Validation sit side by side in OrderMappingModel and FlatMappingModel, and the benchmark checks each claim the last row makes. The last column is what the wire record carries for Bean Validation.

A field that@GenerateMappingMapStruct 1.6Bean Validation, on the wire
keeps its name and typenothingnothing@NotNull
is renamed on the wire@MapField(to = "fullName") String name();@Mapping(target = "fullName", source = "name"), and its inverse or @InheritInverseConfiguration@NotNull
needs a stock conversion: a UUID, an enum, an Instanta leaf returning StandardCodecs.uuid() and friendsnothing: built in@NotNull @Pattern(regexp = …)
is a String on the wire and a number in the domaina leaf, with the domain component Integer, not int: a codec cannot name a primitivenothing: built in@NotNull @Pattern(regexp = …)
has a type of your own, such as an email addressone leaf, a parse and a rendera method each way@NotNull @Pattern(regexp = …)
is a nested record, or a list of thema spec for the nested pairnothing@NotNull @Valid, or @NotNull List<@NotNull @Valid …>
is derived on the wirea default method returning a Getter@Mapping(target = …, expression = "java(…)")nothing
must be checked, every failure locatednothing more: the leaf is the check, and a null is refused, locatednot available: a bad value throws or passes silently, and a null passes ona check of each field's shape: a well-shaped bad value still fails in the conversion, located nowhere

A leaf declared once on a mix-in serves every spec that extends it, as a MapStruct uses = class serves every mapper that names it. What each approach asks of a whole mapper, from Mappers.getMapper to a @ControllerAdvice for violations, is in Coming from MapStruct and Bean Validation.

What a call costs

The order pair is a customer, a list of two line items, a UUID, an Instant, a currency and a status. Its bad wire has five bad fields: a malformed id, email, timestamp and status, and a price in exponent form, 1E+3.

build checks nothing in any approach. On parse, only the generated mapper and Bean Validation check the wire, so each is the other's comparison. The hand-written mapper and plain MapStruct stop at the first problem with an exception that locates nothing, and let other bad values through, so their figures are a floor rather than a rival.

Order pair@GenerateMappingHand-writtenMapStructMapStruct and Bean Validation
build112 ns100 ns88 ns88 ns, as MapStruct
parse, a valid wire2,610 ns394 ns386 ns2,110 ns
memory that valid parse allocates13.6 KB1.8 KB1.8 KB6.7 KB
parse, five bad fields4,670 ns, all five located477 ns, the first thrown534 ns, the first thrown5,600 ns, all five located
the same, 100 frames deeper4,810 ns2,240 ns2,250 ns5,710 ns

Against Bean Validation, a valid parse costs 24 per cent more, and a wire with five bad fields 16 to 17 per cent less. Where nothing validates today, a valid parse adds about 2,200 ns a request, the price of checking and locating every field. A thrown exception's stack trace grows with the caller's depth. So the two mappers that throw cost more than four times as much 100 frames down, the benchmark's stand-in for a web framework's stack. The generated mapper rejects these five fields without an exception: its stock codecs check a value's shape before a parser can throw, and your own leaf can too. The ten-field flat pair tells the same story, with a valid parse 29 per cent over Bean Validation's and five bad fields 21 to 23 per cent under.

Two things favour Bean Validation here. It formats its messages with ParameterMessageInterpolator, which evaluates no expression language, and its patterns check only a field's shape, so a value that passes can still fail in the conversion after it.

Measured on 0.4.11 with MapStruct 1.6.3 and Hibernate Validator 9.1.0.Final, on an AMD Ryzen 9 7950X under Temurin 25.0.1. Each figure is the mean of four forks of five one-second iterations after three of warm-up, and JMH's error margin is under 4 per cent of each. The Mapping Benchmark says how to run it on your own hardware.


Is it a fit for your estate?

Twelve questions about your services

Each ✅ is work the mapper already does. Each ⚠ is a decision to make before adopting, not a blocker. Each ❌ means keep what you have for that case.

Your boundaryVerdict
Domain types are records✅
Domain types are JPA entities or other mutable beans❌ keep MapStruct here
Wire types are records✅
Wire types come from a generated client (getter/setter beans, builders, protobuf-java messages)✅ (a protobuf oneof maps to a sealed type)
Lombok @Data, @Value, @Builder or @SuperBuilder wires, @Singular included✅ (order Lombok before the processor; drop @Singular on a PATCH request)
Lombok @Accessors(fluent = true) wires⚠ the accessors are not getX/isX, so they do not pair
PATCH endpoints where an omitted field means leave unchanged✅ UpdateSpec (a protobuf-java message's through its FieldMask)
PATCH DTOs from openapi-generator with default: values in the schema⚠ the defaults read as sent: see A PATCH getter must answer null until set
PATCH bodies that must distinguish clear from absent✅ an Optional-typed or JsonNullable property
Nested objects patched field by field❌ replacement is wholesale
Clients that need error paths in wire names⚠ paths are domain-named
Spec-carrying libraries on the module path⚠ no cross-module index there

The estate capstone works a generated-client bean, a Lombok @Data bean and a clearing PATCH across three modules.


Where next


Previous: Capstone: An Estate in Three Modules Next: Coming from MapStruct and Bean Validation