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. 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 https://github.com/karunarathnad/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