{"slug": "java-records-deserve-a-mapper-built-for-them", "title": "Java Records Deserve a Mapper Built for Them", "summary": "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.", "body_md": "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.\nBut 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.\nI built Immuto to fill that gap.\nA Record's identity is its canonical constructor:\npublic record PersonDTO(Long id, String fullName, String email) {}\nThat constructor is the only way to create a PersonDTO\n. There are no setters. There is no builder unless you write one yourself. The component accessors are read-only.\nExisting mappers were not designed with this in mind. To work with Records, they either:\nThese are runtime failures. You don't know something is wrong until you run the code.\nImmuto is an annotation processor - it runs during mvn compile\n, the same way Lombok and the APT-based approach work. It generates plain .java\nsource files that call your record's canonical constructor directly. No reflection. No setters. No runtime surprises.\n@RecordMapper\npublic interface PersonMapper {\n@Mapping(target = \"fullName\",\nexpression = \"java(source.firstName() + \\\" \\\" + source.lastName())\")\nPersonDTO toDto(PersonEntity source);\n@InheritInverseConfiguration(name = \"toDto\")\nPersonEntity toEntity(PersonDTO source);\n}\nAfter mvn compile\n, Immuto writes PersonMapperImpl.java\ninto target/generated-sources\n. It looks exactly like code you'd write by hand:\n@Generated(\"io.github.karunarathnad.immuto.processor.RecordMapperProcessor\")\npublic final class PersonMapperImpl implements PersonMapper, ImmutoMapper {\n@Override\npublic PersonDTO toDto(PersonEntity source) {\nif (source == null) return null;\nreturn new PersonDTO(\nsource.id(),\nsource.firstName() + \" \" + source.lastName(),\nsource.email()\n);\n}\n}\nCanonical constructor. Always. That's the contract Immuto enforces.\nIf a record component can't be mapped, the build fails - not at runtime, not in a test, but during compilation.\n@RecordMapper\non a class instead of an interface → build errorThis is the behaviour Records deserve. They were designed to be explicit and safe; your mapper should be too.\nNested records - mapped recursively by matching component names. Use @Mapping(expression=...)\nfor asymmetric nesting.\nBidirectional mapping via @InheritInverseConfiguration\n- define toDto\n, get toEntity\nfor free.\n@NullSafe\n- wraps the result in Optional.ofNullable(...)\nat the call site:\n@NullSafe\nOptional<AddressDTO> toAddressDto(AddressEntity entity);\nSealed class support - Immuto understands sealed hierarchies, something no existing mapper handles.\nLifecycle hooks - @BeforeMapping\nand @AfterMapping\nmethods are inlined into the generated code. No AOP, no proxy.\nCustom type converters:\n@Named(\"isoDate\")\npublic class IsoDateConverter implements TypeConverter<LocalDate, String> {\n@Override\npublic String convert(LocalDate source, MappingContext ctx) {\nreturn source == null ? null : source.toString();\n}\n}\nFluent runtime API - for tests or dynamic environments where APT isn't available:\nFluentMapper<PersonEntity, PersonDTO> mapper = FluentMapper\n.from(PersonEntity.class)\n.to(PersonDTO.class)\n.override(\"fullName\", p -> p.firstName() + \" \" + p.lastName())\n.build();\nNote: FluentMapper\ndoes use reflection - it's the explicit opt-in escape hatch, not the default path.\n<dependency>\n<groupId>io.github.karunarathnad</groupId>\n<artifactId>immuto-annotations</artifactId>\n<version>1.1.0</version>\n</dependency>\n<dependency>\n<groupId>io.github.karunarathnad</groupId>\n<artifactId>immuto-core</artifactId>\n<version>1.1.0</version>\n</dependency>\n<dependency>\n<groupId>io.github.karunarathnad</groupId>\n<artifactId>immuto-processor</artifactId>\n<version>1.1.0</version>\n<scope>provided</scope>\n</dependency>\nAdd the processor path to the compiler plugin:\n<plugin>\n<groupId>org.apache.maven.plugins</groupId>\n<artifactId>maven-compiler-plugin</artifactId>\n<configuration>\n<annotationProcessorPaths>\n<path>\n<groupId>io.github.karunarathnad</groupId>\n<artifactId>immuto-processor</artifactId>\n<version>1.1.0</version>\n</path>\n</annotationProcessorPaths>\n</configuration>\n</plugin>\nThen annotate an interface, run mvn compile\n, and use it:\nPersonMapper mapper = Immuto.getMapper(PersonMapper.class);\nPersonDTO dto = mapper.toDto(entity);\nJava 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.\nImmuto is on Maven Central, Apache 2.0 licensed, and under active development.\nGitHub: github.com/karunarathnad/immuto\nFeedback, issues, and contributions are very welcome.", "url": "https://wpnews.pro/news/java-records-deserve-a-mapper-built-for-them", "canonical_source": "https://dev.to/dinuka_karunarathna/java-records-deserve-a-mapper-built-for-them-302e", "published_at": "2026-05-24 01:33:30+00:00", "updated_at": "2026-05-24 02:02:55.802009+00:00", "lang": "en", "topics": ["developer-tools", "open-source", "enterprise-software"], "entities": ["Java", "Immuto", "PersonDTO", "PersonEntity", "Lombok"], "alternates": {"html": "https://wpnews.pro/news/java-records-deserve-a-mapper-built-for-them", "markdown": "https://wpnews.pro/news/java-records-deserve-a-mapper-built-for-them.md", "text": "https://wpnews.pro/news/java-records-deserve-a-mapper-built-for-them.txt", "jsonld": "https://wpnews.pro/news/java-records-deserve-a-mapper-built-for-them.jsonld"}}