DEV Community

Dinuka Karunarathna
Dinuka Karunarathna

Posted on

Java Records Deserve a Mapper Built for Them

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.

Links

Feedback, issues, and contributions are very welcome.

Top comments (0)