cd /news/developer-tools/java-records-deserve-a-mapper-built-… Β· home β€Ί topics β€Ί developer-tools β€Ί article
[ARTICLE Β· art-12890] src=dev.to β†— pub= topic=developer-tools verified=true sentiment=↑ positive

Java Records Deserve a Mapper Built for Them

The article introduces Immuto, a new Java annotation processor designed specifically for mapping Java Records, which have been stable since Java 16. Unlike existing mappers built for mutable JavaBeans, Immuto generates compile-time code that directly calls a Record's canonical constructor, eliminating reflection and runtime failures. The tool offers features like bidirectional mapping, sealed class support, and lifecycle hooks, ensuring mapping errors are caught during compilation rather than at runtime.

read4 min views24 publishedMay 24, 2026

Java Records have been stable since Java 16, and with Java 21 now the LTS baseline, they're showing up everywhere - DTOs, value objects, domain models. Immutable by design, concise, and semantically clear.

But here's the gap nobody talks about: every object mapper in the Java ecosystem was built before Records existed. They were designed around JavaBeans - mutable objects with getters, setters, and no-arg constructors. Records have none of that. So what happens? These libraries bolt on partial Record support as an afterthought, and the seams show.

I built Immuto to fill that gap.

The problem with retrofitted Record support #

A Record's identity is its canonical constructor:

public record PersonDTO(Long id, String fullName, String email) {}

That constructor is the only way to create a PersonDTO

. There are no setters. There is no builder unless you write one yourself. The component accessors are read-only.

Existing mappers were not designed with this in mind. To work with Records, they either:

  • Generate setter calls that don't exist (and fail at runtime)
  • Require you to write a mutable builder as a workaround
  • Fall back to reflection on private fields - bypassing the canonical constructor entirely

These are runtime failures. You don't know something is wrong until you run the code.

What Immuto does differently #

Immuto is an annotation processor - it runs during mvn compile

, the same way Lombok and the APT-based approach work. It generates plain .java

source files that call your record's canonical constructor directly. No reflection. No setters. No runtime surprises.

@RecordMapper
public interface PersonMapper {

    @Mapping(target = "fullName",
             expression = "java(source.firstName() + \" \" + source.lastName())")
    PersonDTO toDto(PersonEntity source);

    @InheritInverseConfiguration(name = "toDto")
    PersonEntity toEntity(PersonDTO source);
}

After mvn compile

, Immuto writes PersonMapperImpl.java

into target/generated-sources

. It looks exactly like code you'd write by hand:

@Generated("io.github.karunarathnad.immuto.processor.RecordMapperProcessor")
public final class PersonMapperImpl implements PersonMapper, ImmutoMapper {

    @Override
    public PersonDTO toDto(PersonEntity source) {
        if (source == null) return null;
        return new PersonDTO(
            source.id(),
            source.firstName() + " " + source.lastName(),
            source.email()
        );
    }
}

Canonical constructor. Always. That's the contract Immuto enforces.

Compile-time validation #

If a record component can't be mapped, the build fails - not at runtime, not in a test, but during compilation.

  • Unmapped component β†’ build error
  • Type mismatch with no registered converter β†’ build error

@RecordMapper

on a class instead of an interface β†’ build error

This is the behaviour Records deserve. They were designed to be explicit and safe; your mapper should be too.

Key features #

Nested records - mapped recursively by matching component names. Use @Mapping(expression=...)

for asymmetric nesting.

Bidirectional mapping via @InheritInverseConfiguration

  • define toDto

, get toEntity

for free.

** @NullSafe** - wraps the result in

Optional.ofNullable(...)

at the call site:

@NullSafe
Optional<AddressDTO> toAddressDto(AddressEntity entity);

Sealed class support - Immuto understands sealed hierarchies, something no existing mapper handles.

Lifecycle hooks - @BeforeMapping

and @AfterMapping

methods are inlined into the generated code. No AOP, no proxy.

Custom type converters:

@Named("isoDate")
public class IsoDateConverter implements TypeConverter<LocalDate, String> {
    @Override
    public String convert(LocalDate source, MappingContext ctx) {
        return source == null ? null : source.toString();
    }
}

Fluent runtime API - for tests or dynamic environments where APT isn't available:

FluentMapper<PersonEntity, PersonDTO> mapper = FluentMapper
    .from(PersonEntity.class)
    .to(PersonDTO.class)
    .override("fullName", p -> p.firstName() + " " + p.lastName())
    .build();

Note: FluentMapper

does use reflection - it's the explicit opt-in escape hatch, not the default path.

Getting started #

<dependency>
    <groupId>io.github.karunarathnad</groupId>
    <artifactId>immuto-annotations</artifactId>
    <version>1.1.0</version>
</dependency>

<dependency>
    <groupId>io.github.karunarathnad</groupId>
    <artifactId>immuto-core</artifactId>
    <version>1.1.0</version>
</dependency>

<dependency>
    <groupId>io.github.karunarathnad</groupId>
    <artifactId>immuto-processor</artifactId>
    <version>1.1.0</version>
    <scope>provided</scope>
</dependency>

Add the processor path to the compiler plugin:

<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-compiler-plugin</artifactId>
    <configuration>
        <annotationProcessorPaths>
            <path>
                <groupId>io.github.karunarathnad</groupId>
                <artifactId>immuto-processor</artifactId>
                <version>1.1.0</version>
            </path>
        </annotationProcessorPaths>
    </configuration>
</plugin>

Then annotate an interface, run mvn compile

, and use it:

PersonMapper mapper = Immuto.getMapper(PersonMapper.class);
PersonDTO dto = mapper.toDto(entity);

Why now #

Java 21 is the current LTS. Records are not experimental - they're the idiomatic way to model immutable data in modern Java. As more codebases adopt them, the need for tooling that treats them as first-class citizens (not an edge case) grows with it.

Immuto is on Maven Central, Apache 2.0 licensed, and under active development.

Links

GitHub:

github.com/karunarathnad/immutoMaven Central:

https://central.sonatype.com/artifact/io.github.karunarathnad/immuto-core

Feedback, issues, and contributions are very welcome.

── more in #developer-tools 4 stories Β· sorted by recency
── more on @java 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain β€” perfect for shipping the agent you just read about.

$git push zahid main
β†’ Live at https://your-agent.zahid.host βœ“
Get free account β†’ Pricing
from €0/mo Β· no card required
LIVE [news/java-records-deserve…] indexed:0 read:4min 2026-05-24 Β· β€”