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 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 ValidatedPrism.of(this::parse, this::build);
}
/**
* 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 |
| 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 | 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 | ✅ |
| Read-only bean (getters only) | ❌ | ✅ | ❌ | ❌ |
| Write-only bean (setters or builder only) | ✅ | ❌ | ❌ | ❌ |
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 | ❌ | ❌ | ❌ | ❌, not supported yet |
A lossless pair also earns asIso(); a bean pair 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
- Compile time, not run time: the Impl is generated once, per build.
- No reflection and no startup scan. A concrete Impl is a stateless singleton; 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: nothing for an identical field, one for a rename, one for a stock codec (none, where a mix-in already carries it), one for a derived field.
- No component ceiling: a flat 30-field wire maps like a narrow one.
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) | ✅ |
Lombok @Data, @Value or @Builder wires | ✅ (order Lombok before the processor) |
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 |
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 property; JsonNullable is not supported yet |
| 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 |
- 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: Injecting, Testing, and Diagnostics Next: Coming from MapStruct and Bean Validation