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 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 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
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, intermittentlyXImpl.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✅
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 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: 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?

Checkpoint: 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)✅
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

Where next


Previous: Injecting, Testing, and Diagnostics Next: Coming from MapStruct and Bean Validation