{"id":50142800,"url":"https://github.com/karunarathnad/immuto","last_synced_at":"2026-07-05T02:01:16.976Z","repository":{"id":359897440,"uuid":"1245479240","full_name":"karunarathnad/immuto","owner":"karunarathnad","description":"Immuto - Java Records-first object mapper.An annotation processor generates type-safe mapper implementations at compile time; the generated code calls each target record's canonical constructor directly - zero runtime reflection, sealed class support, full Java 21+ idioms.","archived":false,"fork":false,"pushed_at":"2026-07-05T00:12:10.000Z","size":182,"stargazers_count":5,"open_issues_count":0,"forks_count":0,"subscribers_count":1,"default_branch":"master","last_synced_at":"2026-07-05T00:13:21.104Z","etag":null,"topics":["annotation-processor","bean-mapper","canonical-constructor","code-generation","dto-mapper","immutable","java","java-records","mapstruct-alternative","modelmapper-alternative","object-mapper","open-source","record-mapper","sealed-classes","type-safe","zero-reflection"],"latest_commit_sha":null,"homepage":"","language":"Java","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"other","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/karunarathnad.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-05-21T09:00:39.000Z","updated_at":"2026-07-05T00:12:14.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/karunarathnad/immuto","commit_stats":null,"previous_names":["karunarathnad/immuto"],"tags_count":3,"template":false,"template_full_name":null,"purl":"pkg:github/karunarathnad/immuto","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/karunarathnad%2Fimmuto","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/karunarathnad%2Fimmuto/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/karunarathnad%2Fimmuto/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/karunarathnad%2Fimmuto/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/karunarathnad","download_url":"https://codeload.github.com/karunarathnad/immuto/tar.gz/refs/heads/master","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/karunarathnad%2Fimmuto/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":35141083,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-07-05T02:00:06.290Z","response_time":100,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["annotation-processor","bean-mapper","canonical-constructor","code-generation","dto-mapper","immutable","java","java-records","mapstruct-alternative","modelmapper-alternative","object-mapper","open-source","record-mapper","sealed-classes","type-safe","zero-reflection"],"created_at":"2026-05-24T02:08:14.511Z","updated_at":"2026-07-05T02:01:16.965Z","avatar_url":"https://github.com/karunarathnad.png","language":"Java","funding_links":[],"categories":["Projects"],"sub_categories":["Bean Mapping"],"readme":"# Immuto - Java Records-First Object Mapper\n\n[![Maven Central](https://img.shields.io/maven-central/v/io.github.karunarathnad/immuto-core)](https://central.sonatype.com/namespace/io.github.karunarathnad)\n[![CI](https://github.com/karunarathnad/immuto/actions/workflows/ci.yml/badge.svg?branch=master)](https://github.com/karunarathnad/immuto/actions/workflows/ci.yml)\n[![Java 21+](https://img.shields.io/badge/Java-21%2B-blue.svg)](https://openjdk.org/projects/jdk/21/)\n[![License](https://img.shields.io/badge/License-Apache_2.0-green.svg)](LICENSE)\n\nImmuto is the first object mapper designed *specifically* for Java Records.\nUnlike existing libraries (MapStruct, ModelMapper, Orika, JMapper) that treat Records as an afterthought,\nImmuto uses **canonical constructors** as the sole mapping target - no setters, no surprises.\n\n\u003e **How it works in one sentence:** Immuto is an [annotation processor](https://openjdk.org/groups/compiler/doc/compilation-overview/index.html) - \n\u003e `javac` runs it during your normal `mvn compile` step and writes plain `.java` source files to\n\u003e `target/generated-sources`. Those files call your record's canonical constructor directly.\n\u003e **Nothing happens at runtime** beyond loading that generated class.\n\u003e This is the same compile-time approach used by MapStruct and Lombok - not runtime reflection like\n\u003e ModelMapper or Spring's `BeanUtils`.\n\n---\n\n## Why Immuto?\n\n| Library | Records support | When code runs | Null safety | Sealed classes |\n|---|---|---|---|---|\n| MapStruct | Partial (bolted-on) | Compile-time APT → **setters first** | Manual | Partial (explicit `@SubclassMapping` per type) |\n| ModelMapper | Not supported (reflection field-set) | **Runtime reflection** | No | No |\n| Orika | Not supported (bytecode setters) | **Runtime bytecode gen** | No | No |\n| JMapper | Partial | **Runtime byte manipulation** | No | No |\n| **Immuto** | **First-class** | **Compile-time APT → canonical constructor** | **`@NullSafe`** | **Yes (auto-detected, no annotations needed)** |\n\nThe key distinction from MapStruct: MapStruct generates setter calls and adapts to records only as a\nsecondary concern. Immuto treats the canonical constructor as the *only* valid target - if a component\ncannot be mapped at compile time, the build fails with a clear error message.\n\n---\n\n## Quick Start\n\n### 1. Add dependencies\n\n**Maven**\n\n```xml\n\u003c!-- Annotations: only needed at compile time; you can mark it optional --\u003e\n\u003cdependency\u003e\n    \u003cgroupId\u003eio.github.karunarathnad\u003c/groupId\u003e\n    \u003cartifactId\u003eimmuto-annotations\u003c/artifactId\u003e\n    \u003cversion\u003e1.2.1\u003c/version\u003e\n\u003c/dependency\u003e\n\n\u003c!-- Core: the only jar on your runtime classpath --\u003e\n\u003cdependency\u003e\n    \u003cgroupId\u003eio.github.karunarathnad\u003c/groupId\u003e\n    \u003cartifactId\u003eimmuto-core\u003c/artifactId\u003e\n    \u003cversion\u003e1.2.1\u003c/version\u003e\n\u003c/dependency\u003e\n\n\u003c!-- Processor: runs during javac, never on the runtime classpath --\u003e\n\u003cdependency\u003e\n    \u003cgroupId\u003eio.github.karunarathnad\u003c/groupId\u003e\n    \u003cartifactId\u003eimmuto-processor\u003c/artifactId\u003e\n    \u003cversion\u003e1.2.1\u003c/version\u003e\n    \u003cscope\u003eprovided\u003c/scope\u003e\n\u003c/dependency\u003e\n```\n\nTell the compiler plugin where to find the processor:\n\n```xml\n\u003cplugin\u003e\n    \u003cgroupId\u003eorg.apache.maven.plugins\u003c/groupId\u003e\n    \u003cartifactId\u003emaven-compiler-plugin\u003c/artifactId\u003e\n    \u003cconfiguration\u003e\n        \u003cannotationProcessorPaths\u003e\n            \u003cpath\u003e\n                \u003cgroupId\u003eio.github.karunarathnad\u003c/groupId\u003e\n                \u003cartifactId\u003eimmuto-processor\u003c/artifactId\u003e\n                \u003cversion\u003e1.2.1\u003c/version\u003e\n            \u003c/path\u003e\n        \u003c/annotationProcessorPaths\u003e\n    \u003c/configuration\u003e\n\u003c/plugin\u003e\n```\n\n**Gradle**\n\n```groovy\ndependencies {\n    implementation   'io.github.karunarathnad:immuto-core:1.2.1'\n    compileOnly      'io.github.karunarathnad:immuto-annotations:1.2.1'\n    annotationProcessor 'io.github.karunarathnad:immuto-processor:1.2.1'\n}\n```\n\nOr with the Kotlin DSL:\n\n```kotlin\ndependencies {\n    implementation(\"io.github.karunarathnad:immuto-core:1.2.1\")\n    compileOnly(\"io.github.karunarathnad:immuto-annotations:1.2.1\")\n    annotationProcessor(\"io.github.karunarathnad:immuto-processor:1.2.1\")\n}\n```\n\n### 2. Define your Records\n\n```java\npublic record PersonEntity(Long id, String firstName, String lastName,\n                           String email, AddressEntity address) {}\n\npublic record PersonDTO(Long id, String fullName, String email, AddressDTO address) {}\n```\n\n### 3. Declare a mapper interface\n\n```java\n@RecordMapper\npublic interface PersonMapper {\n\n    @Mapping(target = \"fullName\",\n             expression = \"java(source.firstName() + \\\" \\\" + source.lastName())\")\n    PersonDTO toDto(PersonEntity source);\n}\n```\n\n`mvn compile` runs the processor and writes `PersonMapperImpl.java` into\n`target/generated-sources/annotations/`. The generated file looks like this:\n\n```java\n// Written by javac during compilation — not executed until you call it\n@Generated(\"io.github.karunarathnad.immuto.processor.RecordMapperProcessor\")\npublic final class PersonMapperImpl implements PersonMapper, ImmutoMapper {\n\n    @Override\n    public PersonDTO toDto(PersonEntity source) {\n        if (source == null) return null;\n\n        PersonDTO __result = new PersonDTO(    // ← canonical constructor, never a setter\n            source.id(),                       // -\u003e id\n            source.firstName() + \" \" + source.lastName(),  // -\u003e fullName  (expression)\n            source.email(),                    // -\u003e email\n            source.address() == null ? null    // -\u003e address  (null-guarded nested record)\n                : new com.example.AddressDTO(\n                    source.address().street(),\n                    source.address().city(),\n                    source.address().postalCode(),\n                    source.address().country()\n                )\n        );\n\n        return __result;\n    }\n}\n```\n\n### 4. Use it\n\n```java\nPersonMapper mapper = Immuto.getMapper(PersonMapper.class);  // loads PersonMapperImpl via Class.forName\nPersonDTO dto = mapper.toDto(entity);\n```\n\n`Immuto.getMapper` does one `Class.forName` on the first call and caches the instance - no proxy, no\nreflection on every map call, no byte-code generation at runtime.\n\n---\n\n## Features\n\n### Compile-time validation, zero runtime surprises\n\nThe processor validates every target record component before your build finishes:\n\n- Unmapped components → **build error** (not a silent null at runtime)\n- Type mismatch with no registered converter → **build error**\n- `@RecordMapper` on a class instead of an interface → **build error**\n\nHere is what those errors look like - clear, actionable messages pointing at the exact mapper method:\n\n**Unmapped component:**\n```\nerror: [Immuto] Target component 'fullName' has no matching source component.\n       Use @Mapping(target=\"fullName\", ignore=true) to suppress this error.\n       PersonDTO toDto(PersonEntity source);\n```\n\n**Type mismatch without a converter:**\n```\nerror: [Immuto] Cannot auto-convert component 'createdAt': no TypeConverter registered for\n       java.time.Instant → java.lang.String. Register a TypeConverter or add @Mapping(expression=...).\n       PersonDTO toDto(PersonEntity source);\n```\n\n**`@RecordMapper` on a class:**\n```\nerror: [Immuto] @RecordMapper must annotate an interface, found: CLASS\n       public class PersonMapper { ... }\n```\n\nCompare this to MapStruct's equivalent errors, which often reference internal code-generation internals.\nEach Immuto error names the offending component and tells you exactly how to fix it.\n\n### Nested Record mapping\n\nRecord-typed components are mapped by matching component names recursively.\nUse `@Mapping(expression=...)` for asymmetric or computed nesting.\n\n### `List\u003cRecord\u003e` → `List\u003cRecord\u003e`\n\nGenerated code streams and maps element-by-element - no reflection involved.\n\n### `@Mapping` overrides\n\n```java\n@Mapping(target = \"fullName\", expression = \"java(source.firstName() + \\\" \\\" + source.lastName())\")\n@Mapping(target = \"active\",   constant   = \"true\")\n@Mapping(target = \"debug\",    ignore     = true)\n@Mapping(target = \"city\",     source     = \"address.city\")  // dot-notation accessor chain\n```\n\n### Bidirectional mapping\n\n```java\n@InheritInverseConfiguration(name = \"toDto\")\nPersonEntity toEntity(PersonDTO dto);\n```\n\n### Lifecycle hooks\n\n```java\n@BeforeMapping\ndefault void validate(PersonEntity source) {\n    Objects.requireNonNull(source.id(), \"id must not be null\");\n}\n\n@AfterMapping\ndefault void audit(PersonEntity source, PersonDTO target) {\n    AuditLog.record(target);\n}\n```\n\nHook calls are inlined into the generated method body - no proxy, no AOP.\n\n### Optional-safe mapping\n\n```java\n@NullSafe\nOptional\u003cAddressDTO\u003e toAddressDto(AddressEntity entity);\n// generates: return Optional.of(new AddressDTO(...))\n```\n\n### Custom type converters\n\n\u003e **Note:** `uses` and `qualifiedBy` in the annotation-processor path are not yet fully implemented.\n\u003e Declaring them produces a compile-time warning and the attribute is currently ignored.\n\u003e Use `FluentMapper.override(...)` for custom conversions when using the runtime API (see below),\n\u003e or replace the component with an `@Mapping(expression = \"java(...)\")` in the APT path.\n\n```java\n@Named(\"isoDate\")\npublic class IsoDateConverter implements TypeConverter\u003cLocalDate, String\u003e {\n    @Override\n    public String convert(LocalDate source, MappingContext ctx) {\n        return source == null ? null : source.toString();\n    }\n}\n\n// FluentMapper (runtime API) — TypeConverter support works today:\nFluentMapper\u003cEventEntity, EventDTO\u003e mapper = FluentMapper\n    .from(EventEntity.class)\n    .to(EventDTO.class)\n    .override(\"date\", e -\u003e e.date() == null ? null : e.date().toString())\n    .build();\n```\n\n### Spring / CDI integration\n\nAdd `componentModel = \"spring\"` to have the processor annotate the generated implementation\nwith `@Component`, making it injectable via `@Autowired` or constructor injection:\n\n```java\n@RecordMapper(componentModel = \"spring\")\npublic interface PersonMapper {\n    PersonDTO toDto(PersonEntity source);\n}\n```\n\nGenerated output (excerpt):\n\n```java\n@Component\n@Generated(\"io.github.karunarathnad.immuto.processor.RecordMapperProcessor\")\npublic final class PersonMapperImpl implements PersonMapper, ImmutoMapper {\n    ...\n}\n```\n\nThen inject it like any other Spring bean:\n\n```java\n@Service\npublic class PersonService {\n    private final PersonMapper mapper;\n\n    public PersonService(PersonMapper mapper) {   // constructor injection\n        this.mapper = mapper;\n    }\n}\n```\n\n\u003e Note: add `spring-context` to your classpath so `@Component` resolves at compile time.\n\u003e Immuto itself has no Spring dependency — the annotation is generated as a plain string.\n\n### `Collection\u003cRecord\u003e` mapping\n\nBeyond `List`, Immuto 1.2.1 also generates mapping code for `Set\u003cRecord\u003e` and `Map\u003cK, Record\u003e`:\n\n```java\npublic record DeptEntity(Set\u003cEmployeeEntity\u003e members) {}\npublic record DeptDTO   (Set\u003cEmployeeDTO\u003e    members) {}\n\npublic record IndexEntity(Map\u003cString, ProjectEntity\u003e projects) {}\npublic record IndexDTO   (Map\u003cString, ProjectDTO\u003e    projects) {}\n```\n\nBoth are handled automatically by name-matching — no `@Mapping` annotation required.\nThe generated code uses `Collectors.toUnmodifiableSet()` and `Collectors.toUnmodifiableMap()`\nso the results are always unmodifiable.\n\n### MappingContext\n\n`MappingContext` threads arbitrary key/value data through a mapping call tree — useful for\ntenant IDs, locale, audit metadata, or any cross-cutting concern:\n\n```java\nMappingContext ctx = MappingContext.of(\"tenantId\", \"acme\");\n\n// Untyped — caller must cast\nOptional\u003cString\u003e tenant = ctx.get(\"tenantId\");\n\n// Typed — preferred; throws ClassCastException with a clear message on type mismatch\nOptional\u003cString\u003e tenant = ctx.get(\"tenantId\", String.class);\n```\n\nPass it into custom `TypeConverter` implementations:\n\n```java\npublic class TenantAwareConverter implements TypeConverter\u003cPriceEntity, PriceDTO\u003e {\n    @Override\n    public PriceDTO convert(PriceEntity source, MappingContext ctx) {\n        String tenant = ctx.get(\"tenantId\", String.class).orElseThrow();\n        return new PriceDTO(source.amount(), tenant);\n    }\n}\n```\n\n`MappingContext.empty()` returns a shared immutable singleton — calling `put()` on it throws\n`UnsupportedOperationException`. Construct a mutable context with `MappingContext.of(...)`.\n\n### Fluent runtime API (no annotation processor required)\n\nFor dynamic environments, tests, or cases where APT is unavailable:\n\n```java\nFluentMapper\u003cPersonEntity, PersonDTO\u003e mapper = FluentMapper\n    .from(PersonEntity.class)\n    .to(PersonDTO.class)\n    .override(\"fullName\", p -\u003e p.firstName() + \" \" + p.lastName())\n    .build();\n\nPersonDTO dto        = mapper.map(entity);\nList\u003cPersonDTO\u003e dtos = mapper.mapAll(entities);\n```\n\n\u003e **Constraints:** `FluentMapper` matches components by name. Same-named components with compatible types\n\u003e (including primitive↔wrapper pairs such as `long`↔`Long`) are mapped automatically. For other type\n\u003e conversions use `override(...)` or a `TypeConverter`, or prefer the APT path.\n\u003e\n\u003e Note: `FluentMapper` *does* use `java.lang.reflect` to read record components at runtime -\n\u003e it is the explicit opt-in fallback for when you cannot use the annotation processor.\n\u003e The APT-generated path is always reflection-free.\n\n---\n\n## Module structure\n\n```\nio.github.karunarathnad\n├── immuto-annotations   — @RecordMapper, @Mapping, @Named, … (zero dependencies)\n├── immuto-core          — Immuto facade, FluentMapper, TypeConverter SPI, MappingContext\n└── immuto-processor     — javac annotation processor (compile-time only, never on runtime classpath)\n```\n\n---\n\n## Compared to MapStruct\n\n| Feature | MapStruct                                     | Immuto |\n|---|-----------------------------------------------|---|\n| Target paradigm | JavaBeans (setters)                           | Java Records (canonical constructor only) |\n| Records support | Partial - requires mutable builder workaround | First-class |\n| Validation timing | Compile time                                  | Compile time |\n| Runtime reflection | None (generated code)                         | None (generated code) |\n| Sealed classes | No                                            | Yes |\n| `Optional` components | Manual                                        | `@NullSafe` |\n| `Set\u003cRecord\u003e` / `Map\u003cK,Record\u003e` | Manual                                        | Auto-mapped |\n| Spring integration | `componentModel = \"spring\"`                   | `componentModel = \"spring\"` |\n| Fluent runtime API | No                                            | Yes (`FluentMapper`) |\n\n---\n\n## Examples\n\n| Example | Description |\n|---|---|\n| [Spring Boot Quickstart](examples/spring-boot-quickstart) | Spring Boot 3 app with constructor injection, expression mapping, and a REST controller |\n| [Spring Boot Advanced Features](examples/spring-boot-advanced-features) | Six self-contained feature demos targeting the cases MapStruct cannot match (see below) |\n\n### Advanced Features example — what each package shows\n\n| Package | Endpoint | Feature |\n|---|---|---|\n| `nested/` | `GET /orders` | Nested record + `List\u003cRecord\u003e` auto-mapped — no `uses = {OtherMapper.class}` declarations needed |\n| `collections/` | `GET /catalogs` | `Set\u003cRecord\u003e` and `Map\u003cK,Record\u003e` auto-collected into unmodifiable Set/Map |\n| `nullsafe/` | `GET /contacts/{id}` | `@NullSafe` returns `Optional.empty()` on a null source instead of NPE |\n| `hooks/` | `POST /payments` | `@BeforeMapping` validates input; `@AfterMapping` logs audit — both inlined around the constructor call |\n| `inverse/` | `POST /products` | `@InheritInverseConfiguration` derives the reverse rename without repeating the declaration |\n| `dotnotation/` | `GET /shipments` | `source = \"origin.city\"` flattens a nested record field with a generated null-safe accessor chain |\n\n---\n\n## Source \u0026 Issues\n\nGitHub: \u003chttps://github.com/karunarathnad/immuto\u003e\n\n---\n\n## License\n\nApache License 2.0 - see [LICENSE](LICENSE).","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fkarunarathnad%2Fimmuto","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fkarunarathnad%2Fimmuto","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fkarunarathnad%2Fimmuto/lists"}