DEV Community πŸ‘©β€πŸ’»πŸ‘¨β€πŸ’»

waregin
waregin

Posted on

Using MapStruct and Lombok with Inheritance

Lombok and MapStruct are two very useful tools when working with data objects. Lombok provides getters, setters, builders, and constructors, saving you from writing or generating those repetitive methods manually. MapStruct generates code to convert between two objects, such as converting from a Kafka event object into a request object to send to a webservice. However, this post is about a very specific use case: using Lombok and MapStruct to convert between two objects that are subclasses. For instance, converting a CarDto that extends VehicleDto into a CarEvent that extends VehicleEvent. For the sake of simplicity, we will first assume that these parallel objects have the exact same fields with the same names and the same types.

When my pairing partner and I ran into this use case, we were initially stumped. Even our combined Google Fu efforts were not turning up a good solution. However, we eventually discovered a PR for MapStruct that seemed to be the solution to our issues! But, of course, if it were that simple, I would not be writing this now. The SubClassMapping annotation provided by that PR, while quite helpful, requires specific Mapping annotations for every field on the subclass. We wanted a more elegant solution.

After further research, we found that Lombok offers a SuperBuilder annotation. Adding this to both the parent and child classes generates a builder for the child class that can be used to set fields declared in the parent class as well as those declared in the child. However, even though we could use this to set fields from the parent class in an instance of the child class, the auto generated MapperImpl only recognized the parent class fields when calling the builder from the child class.

Finally, we added a BeanMapping annotation to the mapper method telling it to disable the builder. This did the trick! Here is some example code demonstrating our solution.

VehicleDto

import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.experimental.SuperBuilder;

@Data
@SuperBuilder
@NoArgsConstructor
public class VehicleDto {

    private String vin;
    private int numCylinders;

}
Enter fullscreen mode Exit fullscreen mode

CarDto

import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import lombok.experimental.SuperBuilder;

@Data
@SuperBuilder
@EqualsAndHashCode(callSuper=true)
@NoArgsConstructor
public class CarDto extends VehicleDto {

    private String make;
    private String model;
    private int numDoors;

}
Enter fullscreen mode Exit fullscreen mode

VehicleEvent

import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.experimental.SuperBuilder;

@Data
@SuperBuilder
@NoArgsConstructor
public class VehicleEvent {

    private String vin;
    private int numCylinders;

}
Enter fullscreen mode Exit fullscreen mode

CarEvent

import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import lombok.experimental.SuperBuilder;

@Data
@SuperBuilder
@EqualsAndHashCode(callSuper=true)
@NoArgsConstructor
public class CarEvent extends VehicleEvent {

    private String make;
    private String model;
    private int numDoors;

}
Enter fullscreen mode Exit fullscreen mode

VehicleMapper

import org.mapstruct.BeanMapping;
import org.mapstruct.Builder;
import org.mapstruct.Mapper;
import org.mapstruct.SubclassMapping;

@Mapper
public interface VehicleMapper {
    @BeanMapping(builder = @Builder( disableBuilder = true ))
    @SubclassMapping(source = CarDto.class, target = CarEvent.class)
    VehicleEvent toEventData(VehicleDto vehicleDto);
}
Enter fullscreen mode Exit fullscreen mode

VehicleMapperImpl (generated by MapStruct)

import javax.annotation.processing.Generated;

@Generated(
    value = "org.mapstruct.ap.MappingProcessor",
    date = "2022-09-21T17:00:25-0400",
    comments = "version: 1.5.2.Final, compiler: IncrementalProcessingEnvironment from gradle-language-java-7.5.1.jar, environment: Java 11.0.15 (Amazon.com Inc.)"
)
public class VehicleMapperImpl implements VehicleMapper {

    @Override
    public VehicleEvent toEventData(VehicleDto vehicleDto) {
        if ( vehicleDto == null ) {
            return null;
        }

        if (vehicleDto instanceof CarDto) {
            return carDtoToCarEvent( (CarDto) vehicleDto );
        }
        else {
            VehicleEvent vehicleEvent = new VehicleEvent();

            vehicleEvent.setVin( vehicleDto.getVin() );
            vehicleEvent.setNumCylinders( vehicleDto.getNumCylinders() );

            return vehicleEvent;
        }
    }

    protected CarEvent carDtoToCarEvent(CarDto carDto) {
        if ( carDto == null ) {
            return null;
        }

        CarEvent carEvent = new CarEvent();

        carEvent.setVin( carDto.getVin() );
        carEvent.setNumCylinders( carDto.getNumCylinders() );
        carEvent.setMake( carDto.getMake() );
        carEvent.setModel( carDto.getModel() );
        carEvent.setNumDoors( carDto.getNumDoors() );

        return carEvent;
    }
}
Enter fullscreen mode Exit fullscreen mode

VehicleMapperTest

import org.junit.jupiter.api.Test;

import static org.assertj.core.api.Assertions.assertThat;

class VehicleMapperTest {

    @Test
    void shouldMapParentAndChildFieldsWhenMappingChild() {
        CarDto carDto = CarDto.builder()
                .numCylinders(6)
                .vin("2FTJW36M6LCA90573")
                .make("Chevrolet")
                .model("Malibu")
                .numDoors(4)
                .build();
        VehicleMapper mapper = new VehicleMapperImpl();

        CarEvent carEvent = (CarEvent) mapper.toEventData(carDto);

        assertThat(carEvent.getNumCylinders()).isEqualTo(carDto.getNumCylinders());
        assertThat(carEvent.getNumDoors()).isEqualTo(carDto.getNumDoors());
    }
}
Enter fullscreen mode Exit fullscreen mode

As you can see, we elected to keep the SuperBuilder annotations, despite it not being used by the mapper. Primarily, this allows for easy testing. However, please note that if you have the SuperBuilder annotation, you will need the NoArgsConstructor annotation as well. Otherwise, it will generate this code:

        CarEvent.CarEventBuilder<?, ?> b = null;

        CarEvent carEvent = new CarEvent( b );
Enter fullscreen mode Exit fullscreen mode

This throws a NullPointerException from the null builder.

We recommend testing at least one value from the parent class and one from the child class to validate that both sets of fields are being populated, as in the example code. While testing generated code seems a bit redundant, this does ensure that you have the right combination of annotations, as well as all the normal benefits of tests such as quickly finding if a change breaks existing functionality and documenting expected behavior.

Top comments (0)

🌚 Friends don't let friends browse without dark mode.

Sorry, it's true.