Use case
MapStruct provides excellent support for:
- Mapping from a source object to a target object
- Updating an existing mutable target object using
@MappingTarget
- Using builders when creating immutable target objects
However, there is currently no convenient way to perform a deep update of an immutable object graph while preserving information from the existing graph.
Consider:
record Child(
// Internal:
long id,
OffsetDateTime createdAt,
// Public:
int foo
) {}
record Intermediate(
// Public:
boolean bar,
Child child
) {}
record Parent(
// Internal:
long id,
OffsetDateTime createdAt,
// Public:
String baz,
Intermediate intermediate
) {}
This is an immutable structure with three layers of nesting.
Suppose I accept View objects through some API that only expose/accept the public properties:
record ChildView(
int foo
) {}
record IntermediateView(
boolean bar,
ChildView child
) {}
record ParentView(
String baz,
IntermediateView intermediate
) {}
Mapping these is straightforward:
@Mapper(unmappedTargetPolicy = ReportingPolicy.ERROR)
interface ParentMapper {
@Mapping(target = "id", ignore = true)
@Mapping(target = "createdAt", ignore = true)
Parent fromView(ParentView source);
@Mapping(target = "id", ignore = true)
@Mapping(target = "createdAt", ignore = true)
Child fromView(ChildView source);
}
This works well for creation where id an timestamps of creation are generated elsewhere.
Updates, however, are more challenging. A typical update workflow looks like:
updated = merge(updateView, existingModel);
where:
- Values present in the update should overwrite existing values
- Fields not represented in the update should be preserved
- Nested objects should be updated recursively
If my Parent, Intermediate and Child were mutable, it would be simple to patch/update these with values from a ParentView, IntermediateView and ChildView:
@Mapper(unmappedTargetPolicy = ReportingPolicy.ERROR)
interface ParentMapper {
@Mapping(target = "id", ignore = true)
@Mapping(target = "createdAt", ignore = true)
Parent fromView(ParentView source);
@Mapping(target = "id", ignore = true)
@Mapping(target = "createdAt", ignore = true)
Child fromView(ChildView source);
// Don't overwrite `id` and `createdAt`
@Mapping(target = "id", ignore = true)
@Mapping(target = "createdAt", ignore = true)
void patch(@MappingTarget Parent original, ParentView source);
// Don't overwrite `id` and `createdAt`
@Mapping(target = "id", ignore = true)
@Mapping(target = "createdAt", ignore = true)
void patch(@MappingTarget Child original, ChildView source);
}
Of course, this won't work because the Parent, Intermediate and Child are immutable records, not mutable classes.
I am hoping to approach the same convenience and safety with immutable records. The best thing I seem to be able to do is adding Lombok builders to my records and using the source object to initialize that with the attributes that don't exist in the View objects:
@Builder(toBuilder = true)
record Child(
// Like before
) {}
@Builder(toBuilder = true)
record Intermediate(
// Like before
) {}
@Builder(toBuilder = true)
record Parent(
// Like before
) {}
with
@Mapper(unmappedTargetPolicy = ReportingPolicy.ERROR)
interface ParentMapper {
@Mapping(target = "id", ignore = true)
@Mapping(target = "createdAt", ignore = true)
Parent fromView(ParentView source);
@Mapping(target = "id", ignore = true)
@Mapping(target = "createdAt", ignore = true)
Child fromView(ChildView source);
// Don't overwrite `id` and `createdAt`
@Mapping(target = "id", ignore = true)
@Mapping(target = "createdAt", ignore = true)
@Mapping(target = "intermediate", expression = "java(patch(original.intermediate(), source.intermediate()))")
Parent patch(@Context Parent original, ParentView source);
@ObjectFactory
default ParentBuilder toBuilder(@Context Parent original) {
// Seed builder with unmapped target attributes:
return original == null
? Parent.builder()
: original.toBuilder();
}
@Mapping(target = "child", expression = "java(patch(original.child(), source.child()))")
Intermediate patch(@Context Intermediate original, IntermediateView source);
// Don't overwrite `id` and `createdAt`
@Mapping(target = "id", ignore = true)
@Mapping(target = "createdAt", ignore = true)
Child patch(@Context Child original, ChildView source);
@ObjectFactory
default ChildBuilder toBuilder(@Context Child original) {
// Seed builder with unmapped target attributes:
return original == null
? Child.builder()
: original.toBuilder();
}
}
This becomes verbose for large object graphs.
Proposed solution
Introduce a new annotation to identify a “parallel source graph”, that has the same type as the target and is traversed in parallel with the target.
It's a bit like @Context and is not automatically considered as a source for mapping. Also, it resolves like @MappingTarget.
For example:
// Don't overwrite `id` and `createdAt`
@Mapping(target = "id", ignore = true)
@Mapping(target = "createdAt", ignore = true)
Parent patch(@ParallelSource Parent original, ParentView source);
When MapStruct generates nested mapping calls, it would automatically traverse the same property path through both the target and the parallel source object.
This generates something like:
// The .interediate(...) setter here...
builder.intermediate(
patch(
original.intermediate(),
// ...corresponds with the `.intermediate()` getter here:
source.intermediate()
)
);
This replaces the need for a manual mapping like:
@Mapping(target = "intermediate", expression = "java(patch(original.intermediate(), source.intermediate()))")
Here's how that would look:
```java
@Mapper(unmappedTargetPolicy = ReportingPolicy.ERROR)
interface ParentMapper {
@Mapping(target = "id", ignore = true)
@Mapping(target = "createdAt", ignore = true)
Parent fromView(ParentView source);
@Mapping(target = "id", ignore = true)
@Mapping(target = "createdAt", ignore = true)
Child fromView(ChildView source);
// Don't overwrite `id` and `createdAt`
@Mapping(target = "id", ignore = true)
@Mapping(target = "createdAt", ignore = true)
Parent patch(@ParallelSource Parent original, ParentView source);
@ObjectFactory
default ParentBuilder toBuilder(@ParallelSource Parent original) {
// Seed builder with unmapped target attributes:
return original == null
? Parent.builder()
: original.toBuilder();
}
// Could possible even be inferred?
Intermediate patch(@ParallelSource Intermediate original, IntermediateView source);
// Don't overwrite `id` and `createdAt`
@Mapping(target = "id", ignore = true)
@Mapping(target = "createdAt", ignore = true)
Child patch(@ParallelSource Child original, ChildView source);
@ObjectFactory
default ChildBuilder toBuilder(@ParallelSource Child original) {
// Seed builder with unmapped target attributes:
return original == null
? Child.builder()
: original.toBuilder();
}
}
Benefits
Better support for immutable models
The feature significantly reduces boilerplate when working with immutable object graphs and builders.
Improved maintainability
Adding a new nested property would automatically participate in the traversal, instead of requiring manual expressions at every level.
Implementation
I'm willing to implement this feature, but I've never worked on MapStruct before so I would appreciate both some feedback on this proposal and some pointers on where to get started (if the proposal is received well of course!)
MapStruct Version
1.6.X
Use case
MapStruct provides excellent support for:
@MappingTargetHowever, there is currently no convenient way to perform a deep update of an immutable object graph while preserving information from the existing graph.
Consider:
This is an immutable structure with three layers of nesting.
Suppose I accept
Viewobjects through some API that only expose/accept the public properties:Mapping these is straightforward:
This works well for creation where id an timestamps of creation are generated elsewhere.
Updates, however, are more challenging. A typical update workflow looks like:
where:
If my
Parent,IntermediateandChildwere mutable, it would be simple to patch/update these with values from aParentView,IntermediateViewandChildView:Of course, this won't work because the
Parent,IntermediateandChildare immutable records, not mutable classes.I am hoping to approach the same convenience and safety with immutable records. The best thing I seem to be able to do is adding Lombok builders to my records and using the source object to initialize that with the attributes that don't exist in the
Viewobjects:with
This becomes verbose for large object graphs.
Proposed solution
Introduce a new annotation to identify a “parallel source graph”, that has the same type as the target and is traversed in parallel with the target.
It's a bit like
@Contextand is not automatically considered as a source for mapping. Also, it resolves like@MappingTarget.For example:
When MapStruct generates nested mapping calls, it would automatically traverse the same property path through both the target and the parallel source object.
This generates something like:
This replaces the need for a manual mapping like:
Here's how that would look:
Benefits
Better support for immutable models
The feature significantly reduces boilerplate when working with immutable object graphs and builders.
Improved maintainability
Adding a new nested property would automatically participate in the traversal, instead of requiring manual expressions at every level.
Implementation
I'm willing to implement this feature, but I've never worked on MapStruct before so I would appreciate both some feedback on this proposal and some pointers on where to get started (if the proposal is received well of course!)
MapStruct Version
1.6.X