Java Records Deserve a Mapper Built for Them

java dev.to

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) {}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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()
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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();
    }
}
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

Then annotate an interface, run mvn compile, and use it:

PersonMapper mapper = Immuto.getMapper(PersonMapper.class);
PersonDTO dto = mapper.toDto(entity);
Enter fullscreen mode Exit fullscreen mode

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.

GitHub: github.com/karunarathnad/immuto

Feedback, issues, and contributions are very welcome.

Source: dev.to

arrow_back Back to Tutorials