A follow-up to Java Records deserve a mapper
My earlier post introduced Immuto and made the general case for a mapper designed around Records from day one. This one is narrower and more specific: it walks through exactly where MapStruct, which is the mapper most Java teams already use, runs into friction with Records, with real annotations and real generated code on both sides.
MapStruct is excellent. It’s been the go-to compile-time mapper for a decade, and it has supported Records since 1.4.0 by generating direct calls to the canonical constructor. That part works well. To be fair to it upfront: if your Record fields line up cleanly with your entity fields, MapStruct handles that today with no builder and no factory. The gaps below are specific and narrower than “MapStruct can’t do Records,” since they show up in particular corners: asymmetric mappings, defaults, null handling, and sealed hierarchies.
1.Asymmetric mappings and the reverse direction
Where MapStruct’s constructor-based mapping gets genuinely difficult is asymmetric mappings, meaning cases where a target constructor parameter is built from more than one source field via an expression, and you also want the reverse mapping generated automatically.
// MapStruct: forward direction is fine here
@Mapping(target = "fullName",
expression = "java(source.firstName() + \" \" + source.lastName())")
PersonDTO toDto(PersonEntity source);
@InheritInverseConfiguration(name = "toDto")
PersonEntity toEntity(PersonDTO source);
// ↑ MapStruct can't invert "firstName() + \" \" + lastName()" back into
// two separate constructor parameters. It silently passes null for
// whichever parameter it can't resolve. No compile error, no warning,
// just a broken PersonEntity at runtime.
Immuto detects the unresolvable parameter at compile time instead of injecting null:
@RecordMapper
public interface PersonMapper {
@Mapping(target = "fullName", source = "firstName")
PersonDTO toDto(PersonEntity source);
@InheritInverseConfiguration(name = "toDto")
PersonEntity toEntity(PersonDTO source);
// ↑ if a component genuinely can't be resolved, the build fails
// with a named-component error instead of a silent null
}
This is the real, verifiable gap, meaning not “MapStruct can’t map Records,” but rather “MapStruct can’t always invert an asymmetric mapping, and when it can’t, it fails silently instead of at compile time.”
2.Unmapped target components: warning vs. build error
MapStruct’s default unmappedTargetPolicy is WARN. The build succeeds, the field is mapped to null, and the only trace is a compiler warning that's easy to lose in CI output.
// DTO has a 'version' field; entity does not.
// Compiles fine under MapStruct's default WARN policy, so version is null at runtime.
@Mapper
public interface ReportMapper {
ReportDTO toDto(ReportEntity source);
}
MapStruct can be configured to fail the build (@Mapper(unmappedTargetPolicy = ReportingPolicy.ERROR)), so this isn't a capability gap, it's a default-behavior gap. Immuto just ships with the strict option as the default:
// Immuto: same scenario, build fails until you decide explicitly.
// error: [Immuto] Target component 'version' has no matching source component.
// Use @Mapping(target="version", ignore=true) to suppress this error.
@RecordMapper
public interface ReportMapper {
@Mapping(target = "version", ignore = true) // must be explicit
ReportDTO toDto(ReportEntity source);
}
(Immuto exposes the same opt-out in reverse: @RecordMapper(warnOnUnmappedTargetComponents = true) downgrades it to a warning. The difference is which behavior you get without configuring anything.)
3.Optional-returning mappings
MapStruct has real, native Optional support: it can wrap and unwrap Optional properties, and a NullValuePropertyMappingStrategy can turn a null source property into Optional.empty() on the target automatically. That part isn't a gap.
The gap is narrower: when the whole source object passed into a mapping method is null and the method’s return type is Optional, MapStruct doesn't generate the null guard for you, so you still write it at the call site:
// MapStruct: property-level Optional handling is automatic, but the
// method-level null check on the *source object* is manual:
Optional<AddressDTO> toDto(AddressEntity source);
// at the call site:
return source == null ? Optional.empty() : Optional.of(mapper.toDto(source));
Immuto’s @NullSafe moves that specific guard into the mapper declaration:
@NullSafe
Optional<AddressDTO> toDto(AddressEntity source);
// generated:
// if (source == null) return Optional.empty();
// return Optional.of(new AddressDTO(...));
4.@AfterMapping on a Record target is observation-only
Both libraries support @BeforeMapping and @AfterMapping. MapStruct's @AfterMapping was designed with JavaBeans in mind, where the hook receives a @MappingTarget object it can still mutate via setters. For a Record target, that design doesn't transfer cleanly: the hook fires after the canonical constructor has already produced an immutable object, so there are no setters left to call. MapStruct doesn't warn you about this, so the annotation just quietly becomes observation-only.
Immuto makes that explicit rather than implicit:
@RecordMapper
public interface PaymentMapper {
PaymentDTO toDto(PaymentEntity source);
@BeforeMapping
default void validate(PaymentEntity source) {
Objects.requireNonNull(source.amount(), "amount must not be null");
}
@AfterMapping
default void audit(PaymentEntity source, PaymentDTO target) {
AuditLog.record(target); // target is already built and immutable
}
}
// generated
@Override
public PaymentDTO toDto(PaymentEntity source) {
if (source == null) return null;
validate(source);
PaymentDTO __result = new PaymentDTO(source.id(), source.amount(), source.currency());
audit(source, __result);
return __result;
}
5.Sealed interface hierarchies
MapStruct can map a sealed interface to a sealed interface, but every subtype pair needs an explicit @SubclassMapping on the parent-mapping method, even though the full set of permitted subtypes is already known at compile time from the permits clause. (As of MapStruct 1.6, a sealed source no longer needs subclassExhaustiveStrategy to prove exhaustiveness, but the per-pair annotations are still required.)
// MapStruct: one @SubclassMapping per subtype pair, every time.
@Mapper
public interface ShapeMapper {
@SubclassMapping(source = CircleEntity.class, target = CircleDTO.class)
@SubclassMapping(source = SquareEntity.class, target = SquareDTO.class)
ShapeDTO toDto(ShapeEntity source);
}
Immuto reads the sealed interface’s permits list directly: declare one method per subtype, no subclass-mapping annotation required.
@RecordMapper
public interface ShapeMapper {
ShapeDTO toDto(CircleEntity source);
ShapeDTO toDto(SquareEntity source);
// dispatch is generated from the sealed interface's permitted subtypes
}
(If you’re implementing this, it’s worth deciding up front what happens when a new subtype is added later and no matching method exists, since failing the build there is what keeps this feature consistent with the “explicit over silent” theme running through the rest of this piece.)
6.Error messages that name the component
This one is more UX than capability, but it’s part of the same philosophy: Immuto’s compiler errors are written around the Record’s component model, and each one names the exact component and the fix:
error: [Immuto] Target component 'fullName' has no matching source component.
Use @Mapping(target="fullName", ignore=true) to suppress this error.
PersonDTO toDto(PersonEntity source);
^
error: [Immuto] Cannot auto-convert component 'createdAt': no TypeConverter
registered for java.time.Instant → java.lang.String.
Register a TypeConverter or add @Mapping(expression="java(...)").
PersonDTO toDto(PersonEntity source);
At a glance
- Straightforward Record mapping (matching fields) MapStruct: Native, constructor-based, works well Immuto: Native, constructor-based
- Asymmetric / expression-based reverse mappings MapStruct: Can silently pass null for unresolvable parameters Immuto: Compile-time error on unresolvable parameters
- Unmapped target components MapStruct: WARN by default (configurable to ERROR) Immuto: Build error by default (configurable to warn)
- Optional-returning methods MapStruct: Property-level Optional handling is automatic; method-level source-null guard is manual Immuto:
@NullSafegenerates the source-null guard automatically -
@AfterMappingon a Record target MapStruct: Supported, but silently observation-only (no setters exist) Immuto: Explicit observation hook, documented as such - Sealed interface mapping MapStruct: Supported via explicit
@SubclassMappingper subtype pair Immuto: Declared via per-subtype methods, dispatch inferred from permits
Getting started
<dependency>
<groupId>io.github.karunarathnad</groupId>
<artifactId>immuto-annotations</artifactId>
<version>1.2.1</version>
</dependency>
<dependency>
<groupId>io.github.karunarathnad</groupId>
<artifactId>immuto-core</artifactId>
<version>1.2.1</version>
</dependency>
<dependency>
<groupId>io.github.karunarathnad</groupId>
<artifactId>immuto-processor</artifactId>
<version>1.2.1</version>
<scope>provided</scope>
</dependency>
Immuto is on Maven Central, Apache 2.0 licensed, and under active development.
Links
- GitHub: Immuto
- Maven Central: Maven Artifact
- Earlier post: Java Records deserve a mapper
Feedback, issues, and contributions are very welcome