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
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 decision | Why it is that way | What you do about it |
|---|---|---|
| A conversion is declared per component, never inferred from the types | An implicit String to UUID conversion is a guess about a boundary the library cannot see | Declare a leaf, and share the common ones through one mix-in vocabulary |
| Error paths use domain component names | Paths stay stable when the wire is renamed | Where 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 otherwise | On most wires a null really is a defect | @OptionalBridge per component; automatic on bean wires |
| The domain must be a record | parse builds through the canonical constructor | Keep 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 types | Records, sealed hierarchies and beans from a compiled library cannot be edited | Declare the spec in your own code: @GenerateMapping interface VendorOrderMapping extends MappingSpec<VendorOrder, OrderDto> {} |
| Sparse PATCH is opt-in, bean-only and wrapper-typed | A primitive can never be absent, and a record component is always present | UpdateSpec with Integer, Boolean and friends |
| A PATCH replaces a nested object wholesale | Deep merge is out of scope | Patch the nested record through its own spec |
| A spec-carrying module on the module path neither writes nor reads the cross-module index | The index is one package, and a package belongs to one module | Delegate 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 spec | Class initialisation can leave that constant null, intermittently; the processor warns at one typed as the Impl | XImpl.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 shape | build | parse | Projection write-back | updateFrom |
|---|---|---|---|---|
| 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 leaf | through a leaf | through 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
Validatedper 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 | @GenerateMapping | MapStruct 1.6 | Bean Validation, on the wire |
|---|---|---|---|
| keeps its name and type | nothing | nothing | @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 Instant | a leaf returning StandardCodecs.uuid() and friends | nothing: built in | @NotNull @Pattern(regexp = …) |
is a String on the wire and a number in the domain | a leaf, with the domain component Integer, not int: a codec cannot name a primitive | nothing: built in | @NotNull @Pattern(regexp = …) |
| has a type of your own, such as an email address | one leaf, a parse and a render | a method each way | @NotNull @Pattern(regexp = …) |
| is a nested record, or a list of them | a spec for the nested pair | nothing | @NotNull @Valid, or @NotNull List<@NotNull @Valid …> |
| is derived on the wire | a default method returning a Getter | @Mapping(target = …, expression = "java(…)") | nothing |
| must be checked, every failure located | nothing more: the leaf is the check, and a null is refused, located | not available: a bad value throws or passes silently, and a null passes on | a 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 | @GenerateMapping | Hand-written | MapStruct | MapStruct and Bean Validation |
|---|---|---|---|---|
build | 112 ns | 100 ns | 88 ns | 88 ns, as MapStruct |
parse, a valid wire | 2,610 ns | 394 ns | 386 ns | 2,110 ns |
memory that valid parse allocates | 13.6 KB | 1.8 KB | 1.8 KB | 6.7 KB |
parse, five bad fields | 4,670 ns, all five located | 477 ns, the first thrown | 534 ns, the first thrown | 5,600 ns, all five located |
| the same, 100 frames deeper | 4,810 ns | 2,240 ns | 2,250 ns | 5,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?
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 boundary | Verdict |
|---|---|
| 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.
- Five steps to a working endpoint: Quickstart
- Coming from another mapper: Coming from MapStruct and Bean Validation
- What each spec shape generates: What Your Spec Generates
- The complete set of limits: Rules and Limits
Previous: Capstone: An Estate in Three Modules Next: Coming from MapStruct and Bean Validation