blog

Using @AfterMapping with Mapstruct, Interfaces, and Lombok Builders

October 12, 2023

Mike Cain

TLDR

When using interfaces to define mapping and POJOs annotated with Lombok’s Builder, the signature for afterMapping needs to match the entry point of the mapper, with the additional parameter of the mapping target’s builder.

The Problem

While working at a client, there was a somewhat complex task to map data from the legacy system to the greenfield system. The requirement was to update two target fields depending on another source field’s value.  Being somewhat new to Mapstruct, but not search engines, I came across the @AfterMapping capability and I felt this was the way to go.  The examples I found were not working for me because the afterMapping method was never called.  That is, until I discovered the following solution:

Let’s say you have SourceClassA with some fields and a list of SourceClassB.  Note that we use Lombok.


@Data
@AllArgsConstructor
@Builder(toBuilder = true)
public class SourceClassA {
   private String fieldA;
   private Integer fieldB;
   private String fieldC;
   private List<SourceClassB> sourceClassBList;
}


@Data
@AllArgsConstructor
@Builder(toBuilder = true)
public class SourceClassB {
   private String classBFieldA;
   private String classBFieldB;
}

Here is our TargetClass:


@Data
@AllArgsConstructor
@Builder(toBuilder = true)
public class TargetClass {
   private String fieldA;
   private String fieldB;
   private String fieldC;
   private String fieldD;
}

The task is to map every SourceClassA and SourceClassB to one TargetClass (i.e. if an instance of SourceClassA has a list of 10 SourceClassBs, then there would be 10 TargetClasses).  In addition to the standard mapping, we need to change the values of two fields on the target class if a field on the source class has a specific value.  Just to make it more fun, we also need to map the target field D to the combination of field A and B from SourceClassB.

The Solution

Given that, the mapper would look like so:


@Mapper(
       unmappedTargetPolicy = ERROR,
       unmappedSourcePolicy = IGNORE,
       injectionStrategy = InjectionStrategy.CONSTRUCTOR)
public interface TargetClassMapper {
   @Mapping(target = "fieldD", expression = "java(mapFieldD(b))")
   TargetClass toTargetClass(SourceClassA a, SourceClassB b);


   default String mapFieldD(SourceClassB b) {
       return b.getClassBFieldA() + ":" + b.getClassBFieldB();
   }


   @AfterMapping
   default void afterMapping(SourceClassA a, SourceClassB b, @MappingTarget TargetClass.TargetClassBuilder builder) {
       if (a.getFieldB() > 10) {
           builder.fieldA("foo");
           builder.fieldB("bar");
       }
   }
}

Notice that the method signature for afterMapping matches the entry point for mapping, with the addition of @MappingTarget TargetClass.TargetClassBuilder builder.  Without this, the MapStruct will not be able to call the afterMapping method. 

The Proof

Here is a handy JUnit test for proof that fields A and B are changed when field B is greater than 10:


public class TargetClassMapperTest {


   TargetClassMapper mapper = new TargetClassMapperImpl();


   @Test
   public void testAfterMappingChangesValues() {
       SourceClassA a = SourceClassA.builder()
               .fieldA("fieldA")
               .fieldB(20)
               .fieldC("fieldC")
               .sourceClassBList(List.of(
                       SourceClassB.builder()
                               .classBFieldA("classBFieldA1")
                               .classBFieldB("classBFieldB1")
                               .build(),
                       SourceClassB.builder()
                               .classBFieldA("classBFieldA2")
                               .classBFieldB("classBFieldB2")
                               .build()
               ))
               .build();;


       TargetClass targetClass = mapper.toTargetClass(a, a.getSourceClassBList().get(0));


       assertThat(targetClass).isNotNull().satisfies(target -> {
           assertThat(target.getFieldA()).isEqualTo("foo");
           assertThat(target.getFieldB()).isEqualTo("bar");
           assertThat(target.getFieldC()).isEqualTo("fieldC");
           assertThat(target.getFieldD()).isEqualTo("classBFieldA1:classBFieldB1");
       });
   }


   @Test
   public void testAfterMappingDoesNotChangeValues() {
       SourceClassA a = SourceClassA.builder()
               .fieldA("fieldA")
               .fieldB(5)
               .fieldC("fieldC")
               .sourceClassBList(List.of(
                       SourceClassB.builder()
                               .classBFieldA("classBFieldA1")
                               .classBFieldB("classBFieldB1")
                               .build(),
                       SourceClassB.builder()
                               .classBFieldA("classBFieldA2")
                               .classBFieldB("classBFieldB2")
                               .build()
               ))
               .build();
      
       TargetClass targetClass = mapper.toTargetClass(a, a.getSourceClassBList().get(0));


       assertThat(targetClass).isNotNull().satisfies(target -> {
           assertThat(target.getFieldA()).isEqualTo("fieldA");
           assertThat(target.getFieldB()).isEqualTo("5");
           assertThat(target.getFieldC()).isEqualTo("fieldC");
           assertThat(target.getFieldD()).isEqualTo("classBFieldA1:classBFieldB1");
       });
   }
}

The example above is somewhat contrived and my code for the client was much nicer, but hopefully this will help in case of this complex mapping scenario!