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
-
@RecordMapperon 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/immuto
Maven Central: https://central.sonatype.com/artifact/io.github.karunarathnad/immuto-core
Feedback, issues, and contributions are very welcome.
Top comments (0)