Java Records have been stable since Java 16, and with Java 25 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.
To be clear: MapStruct and Spring Boot both support Records well when field names match exactly between source and target. The limitation appears in asymmetric mappings - for example, when firstName and lastName on an entity need to map to a single fullName component on a Record DTO. In that case, MapStruct cannot resolve the canonical constructor parameter and silently maps it as null at runtime. Immuto was built specifically to handle these cases using the canonical constructor as the primary contract, turning that silent runtime failure into a compile-time error.
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
Records have been production-ready since Java 16, fully available through both the Java 17 and Java 21 LTS cycles. Java 25 is now the current LTS (released September 2025), with Java 21 as the previous LTS. As more codebases have adopted Records across these LTS versions, the need for tooling that treats them as first-class citizens - not an edge case - has grown 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)