Deep dive into Mapstruct @ Spring

Miguel Duque
UpHill Health | Engineering & Design
10 min readDec 14, 2020
Photo by Beau Swierstra on Unsplash

Being a Java developer, you definitely know that mapping POJOs is a standard task when developing multilayered applications.
Writing these mappings manually, besides being a boring and unpleasant task for developers, is also error-prone.

MapStruct is an open-source Java library that generates mapper class implementations during compilation in a safe and easy way.

During this article, we will follow an example of how to take advantage of this powerful library to greatly reduce the amount of boilerplate code that would regularly be written by hand.

Index

  • Basic Mappings
  • Mapping Different Property Name
  • Making sure every property is mapped
  • Mapping Child Entity property
  • Mapping the full Child Entity — Using another mapper
  • Mapping with a custom method
  • @BeforeMapping @AfterMapping
  • Mapping with an additional parameter
  • Dependency injection on mapping methods
  • Updates
  • Patch Updates

Basic Mappings

Let’s start our application with a basic model, which contains the class Doctor. Our service will retrieve this class from the model layer and then return a DoctorDto class.

Model class:

@Data
public class Doctor {
private int id;
private String name;
}

Dto class:

@Data
public class DoctorDto {
private int id;
private String name;
}

To do this, we should create our Mapper interface:

@Mapper(componentModel = "spring")
public interface DoctorMapper {
DoctorDto toDto(Doctor doctor);
}

As both classes have the same property names (id and name), mapstruct will include the mapping of both fields in the generated class:

@Component
public class DoctorMapperImpl implements DoctorMapper {

@Override
public DoctorDto toDto(Doctor doctor) {
if ( doctor == null ) {
return null;
}

DoctorDto doctorDto = new DoctorDto();

doctorDto.setId( doctor.getId() );
doctorDto.setName( doctor.getName() );

return doctorDto;
}
}

By adding componentModel = “spring”, the generated mapper will be a Spring bean and can be retrieved with the @Autowired annotation like any other bean:

@Service
public class DoctorService {

private final DoctorMapper doctorMapper;
private final DoctorRepository doctorRepository;

@Autowired
public DoctorService(DoctorMapper doctorMapper,
DoctorRepository doctorRepository) {
this.doctorMapper = doctorMapper;
this.doctorRepository = doctorRepository;
}

public DoctorDto getDoctor(Integer id) {
Doctor doctor = doctorRepository.findById(id);
return doctorMapper.toDto(doctor);
}
}

Mapping Different Property Name

If we include a property phone in the Doctor class:

@Data
public class Doctor {
private int id;
private String name;
private String phone;
}

To be mapped to contact in DoctorDto:

@Data
public class DoctorDto {
private int id;
private String name;
private String contact;
}

Our mapper won't be able to map it automatically. To do this, we will need to create a rule for this mapping:

@Mapper(componentModel = "spring")
public interface DoctorMapper {

@Mapping(source = "phone", target = "contact")
DoctorDto toDto(Doctor doctor);
}

Making sure every property is mapped

If we want to guarantee that we don't forget to map any target property, we can configure the unmappedTargetPolicy option on our mapper:

@Mapper(
componentModel = "spring",
unmappedTargetPolicy = ReportingPolicy.ERROR)
public interface DoctorMapper {

@Mapping(source = "phone", target = "contact")
DoctorDto toDto(Doctor doctor);
}

With this configuration, if we remove

@Mapping(source = "phone", target = "contact")

our code will fail during compilation with the error:

Unmapped target property: “contact”. DoctorDto toDto(Doctor doctor);

If for some reason, we want to ignore a target property, we can add:

@Mapping(target = "contact", ignore = true)

Similarly, we can also guarantee that all source properties are mapped by configuring the unmappedSourcePolicy option.

Mapping Child Entity property

Most of the time, the class that we need to map contains child objects. For example:

@Data
public class Doctor {
private int id;
private String name;
private String phone;
private Speciality speciality;
}
@Data
public class Speciality {
private int id;
private String name;
}

And in our Dto, instead of the full speciality, we just need its name:

@Data
public class DoctorDto {
private int id;
private String name;
private String contact;
private String specialityName;
}

This situation is also straightforward with mapstruct:

@Mapping(source = "phone", target = "contact")
@Mapping(source = "speciality.name", target = "specialityName")
DoctorDto toDto(Doctor doctor);

Mapping the full Child Entity — Using another mapper

As before, our Doctor class has a child object Address:

@Data
public class Doctor {
private int id;
private String name;
private String phone;
private Speciality speciality;
private Address address;
}

But in this case, we want to map it to a new object in our DoctorDto class:

@Data
public class DoctorDto {
private int id;
private String name;
private String contact;
private String specialityName;
private AddressDto address;
}

To perform the mapping between Address and AddressDto class, we should create a different mapper interface:

@Mapper(componentModel = "spring")
public interface AddressMapper {
AddressDto toDto(Address address);
}

Then, in our DoctorMapper we should make sure that this mapper is used when mapping the Doctor to DoctorDto. This can be done with the "uses" option on the mapper configuration:

@Mapper(
componentModel = "spring",
unmappedTargetPolicy = ReportingPolicy.ERROR,
uses = {AddressMapper.class})
public interface DoctorMapper {

@Mapping(source = "phone", target = "contact")
@Mapping(source = "speciality.name", target = "specialityName")
DoctorDto toDto(Doctor doctor);
}

We will see that our DoctorMapperImpl will Autowire and use our AddressMapper:

@Component
public class DoctorMapperImpl implements DoctorMapper {

@Autowired
private AddressMapper addressMapper;

@Override
public DoctorDto toDto(Doctor doctor) {
if ( doctor == null ) {
return null;
}

DoctorDto doctorDto = new DoctorDto();

doctorDto.setSpecialityName( doctorSpecialityName(doctor));
doctorDto.setContact( doctor.getPhone() );
doctorDto.setId( doctor.getId() );
doctorDto.setName( doctor.getName() );
doctorDto.setAddress(
addressMapper.toDto( doctor.getAddress() ) );


return doctorDto;
}

...
}

Mapping with a custom method

Let's now add a list of patients to our Doctor class:

@Data
public class Doctor {
private int id;
private String name;
private String phone;
private Speciality speciality;
private Address address;
private List<Patient> patients;
}

However, on our DoctorDto we only want the number of patients:

@Data
public class DoctorDto {
private int id;
private String name;
private String contact;
private String specialityName;
private AddressDto address;
private int numPatients;
}

This mapping requires 2 things:

  • A custom method with the @Named annotation
  • The qualifiedByName config on the Mapping annotation
@Mapper(
componentModel = "spring",
unmappedTargetPolicy = ReportingPolicy.ERROR,
uses = {AddressMapper.class})
public interface DoctorMapper {

@Mapping(source = "phone", target = "contact")
@Mapping(source = "speciality.name", target = "specialityName")
@Mapping(
source = "patients",
target = "numPatients",
qualifiedByName = "countPatients")

DoctorDto toDto(Doctor doctor);

@Named("countPatients")
default int getNumPatients(List<Patient> patients) {
if(patients == null) {
return 0;
}
return patients.size();
}
}

@BeforeMapping @AfterMapping

The previous example (mapping from List<Patient> patients to int numPatients) can also be done with @BeforeMapping and @AfterMapping

@Mapper(
componentModel = "spring",
unmappedTargetPolicy = ReportingPolicy.ERROR,
uses = {AddressMapper.class})
public interface DoctorMapper {

@BeforeMapping
default void setList(Doctor doctor) {
if (doctor != null && doctor.getPatients() == null) {
doctor.setPatients(new ArrayList<>());
}
}

@Mapping(source = "phone", target = "contact")
@Mapping(source = "speciality.name", target = "specialityName")
@Mapping(target = "numPatients", ignore = true)
DoctorDto toDto(Doctor doctor);

@AfterMapping
default void setNumPatients(Doctor doctor,
@MappingTarget DoctorDto doctorDto) {
doctorDto.setNumPatients(doctor.getPatients().size());
}
}

These methods will be called in the beginning and end of our generated mapping method:

@Override
public DoctorDto toDto(Doctor doctor) {
setList( doctor );

if ( doctor == null ) {
return null;
}

DoctorDto doctorDto = new DoctorDto();

doctorDto.setSpecialityName( doctorSpecialityName( doctor ) );
doctorDto.setContact( doctor.getPhone() );
doctorDto.setId( doctor.getId() );
doctorDto.setName( doctor.getName() );
doctorDto.setAddress( addressMapper.toDto(doctor.getAddress()));

setNumPatients( doctor, doctorDto );

return doctorDto;
}

Mapping with an additional parameter

Let's check how to handle a situation where your mapper needs to receive an additional parameter, besides your Entity.

In this case, your Doctor class can only get the City Id:

@Data
public class Doctor {
private int id;
private String name;
private String phone;
private Speciality speciality;
private Address address;
private List<Patient> patients;
private int cityId;
}

But needs to map it to the City Name:

@Data
public class DoctorDto {
private int id;
private String name;
private String contact;
private String specialityName;
private AddressDto address;
private int numPatients;
private String cityName;
}

Your service will fetch the list of cities and pass them to the mapper

@Service
public class DoctorService {

private final DoctorMapper doctorMapper;
private final DoctorRepository doctorRepository;

@Autowired
public DoctorService(DoctorMapper doctorMapper) {
this.doctorMapper = doctorMapper;
this.doctorRepository = doctorRepository;
}

public DoctorDto getDoctor(Integer id) {
Doctor doctor = doctorRepository.findById(id);
List<City> cities = getCities();
return doctorMapper.toDto(doctor, cities);
}
}

In our mapper, we need to:

  • mark the additional parameter (list of cities) with @Context annotation
  • create a custom method to handle our mapping
  • request the context parameter (list of cities) on our custom mapping method
@Mapping(source = "phone", target = "contact")
@Mapping(source = "speciality.name", target = "specialityName")
@Mapping(target = "numPatients", ignore = true)
@Mapping(source = "cityId",
target = "cityName",
qualifiedByName = "cityName")

DoctorDto toDto(Doctor doctor, @Context List<City> cities);

@Named("cityName")
default String getCityName(int cityId, @Context List<City> cities) {
return cities.stream()
.filter(city -> city.getId() == cityId)
.findAny()
.map(City::getName)
.orElse(null);
}

Dependency injection on mapping methods

You will probably find yourself in situations where your custom mapping methods require another bean (another mapper, a repository, a service, etc).

In these situations, you will need to Autowire that bean to your mapper, so let's see an example of how to do it.

In this example, our Patient class will be an abstract class:

@Data
public abstract class Patient {
private int id;
private String name;
private int age;
}

Which contains two implementations:

public class Man extends Patient {
}
public class Woman extends Patient {
}

As we previously saw, this is the state of our Doctor entity:

@Data
public class Doctor {
private int id;
private String name;
private String phone;
private Speciality speciality;
private Address address;
private List<Patient> patients;
private int cityId;
}

However, our PatientDto requires a distinct list for each concrete class:

@Data
public class DoctorDto {
private int id;
private String name;
private String contact;
private String specialityName;
private AddressDto address;
private int numPatients;
private String cityName;
private PatientsDto patients;
}

And

@Data
public class PatientsDto {
private List<ManDto> men = new ArrayList<>();
private List<WomanDto> women = new ArrayList<>();

public void addMan(ManDto manDto) {
men.add(manDto);
}

public void addWoman(WomanDto womanDto) {
women.add(womanDto);
}
}

So to map these concrete classes, we should start by creating two mappers:

@Mapper(componentModel = "spring")
public interface WomanMapper {
WomanDto toDto(Woman woman);
}
@Mapper(componentModel = "spring")
public interface ManMapper {
ManDto toDto(Man man);
}

But to map from List<Patient> patients to PatientsDto patients we also need a different mapper that uses the newly created mappers (WomanMapper and ManMapper), that will end up being used by our DoctorMapper.

Since our PatientsMapper will need to use the WomanMapper and ManMapper, instead of creating an Interface, we need to create an Abstract class:

@Mapper(componentModel = "spring")
public abstract class PatientsMapper {

@Autowired
private ManMapper manMapper;

@Autowired
private WomanMapper womanMapper;

public PatientsDto toDto(List<Patient> patients) {
PatientsDto patientsDto = new PatientsDto();
for (Patient patient : patients) {
if (patient instanceof Man) {
patientsDto.addMan(
manMapper.toDto((Man) patient));
} else if (patient instanceof Woman) {
patientsDto.addWoman(
womanMapper.toDto((Woman) patient));
}
}
return patientsDto;
}
}

Finally, to make our DoctorMapper use the PatientsMapper, we need to add some configuration:

@Mapper(
componentModel = "spring",
unmappedTargetPolicy = ReportingPolicy.ERROR,
uses = {AddressMapper.class, PatientsMapper.class})
public interface DoctorMapper {
...
}

As the patients variable has the same name on the Entity and Dto classes, we don't need to specify anything else.

This will be the end result of the generated classes:

@Component
public class DoctorMapperImpl implements DoctorMapper {

@Autowired
private AddressMapper addressMapper;
@Autowired
private PatientsMapper patientsMapper;


@Override
public DoctorDto toDto(Doctor doctor, List<City> cities) {
if (doctor == null) {
return null;
}

DoctorDto doctorDto = new DoctorDto();

doctorDto.setNumPatients(
getNumPatients(doctor.getPatients()));
doctorDto.setCityName(
getCityName(doctor.getCityId(), cities));
doctorDto.setSpecialityName(doctorSpecialityName(doctor));
doctorDto.setContact(doctor.getPhone());
doctorDto.setId(doctor.getId());
doctorDto.setName(doctor.getName());
doctorDto.setAddress(
addressMapper.toDto(doctor.getAddress()));
doctorDto.setPatients(
patientsMapper.toDto(doctor.getPatients()));


return doctorDto;
}
...
}
@Component
public class ManMapperImpl implements ManMapper {

@Override
public ManDto toDto(Man man) {
if ( man == null ) {
return null;
}

ManDto manDto = new ManDto();

manDto.setId( man.getId() );
manDto.setName( man.getName() );
manDto.setAge( man.getAge() );

return manDto;
}
}
@Component
public class WomanMapperImpl implements WomanMapper {

@Override
public WomanDto toDto(Woman woman) {
if ( woman == null ) {
return null;
}

WomanDto womanDto = new WomanDto();

womanDto.setId( woman.getId() );
womanDto.setName( woman.getName() );
womanDto.setAge( woman.getAge() );

return womanDto;
}
}

Updates

Mapstruct also provides an easy way to handle updates. If we want to update our Doctor entity with the information on DoctorDto, we just need to create:

@Mapping(source = "contact", target = "phone")
@Mapping(source = "specialityName", target = "speciality.name")
void updateEntity(DoctorDto doctorDto,
@MappingTarget Doctor doctor);

As we can see on the generated implementation, it will map all variables (null or not null):

@Override
public void updateEntity(DoctorDto doctorDto, Doctor doctor) {
if ( doctorDto == null ) {
return;
}

if ( doctor.getSpeciality() == null ) {
doctor.setSpeciality( new Speciality() );
}
doctorDtoToSpeciality( doctorDto, doctor.getSpeciality() );
doctor.setPhone( doctorDto.getContact() );
doctor.setId( doctorDto.getId() );
doctor.setName( doctorDto.getName() );
if ( doctorDto.getAddress() != null ) {
if ( doctor.getAddress() == null ) {
doctor.setAddress( new Address() );
}
addressDtoToAddress( doctorDto.getAddress(), doctor.getAddress() );
}
else {
doctor.setAddress( null );
}
}

Patch Updates

As we have seen in the previous example, the default update method will map every property, even if it is null. So if you encounter yourself in a situation where you just want to perform a patch update (only update the not null values), you need to use the nullValuePropertyMappingStrategy:

@BeanMapping(nullValuePropertyMappingStrategy = 
NullValuePropertyMappingStrategy.IGNORE)

@Mapping(source = "contact", target = "phone")
@Mapping(source = "specialityName", target = "speciality.name")
void updatePatchEntity(DoctorDto doctorDto,
@MappingTarget Doctor doctor);

The generated method will perform null checks before updating the values:

@Override
public void updatePatchEntity(DoctorDto doctorDto, Doctor doctor) {
if ( doctorDto == null ) {
return;
}

if ( doctorDto.getContact() != null ) {
doctor.setPhone( doctorDto.getContact() );
}
doctor.setId( doctorDto.getId() );
if ( doctorDto.getName() != null ) {
doctor.setName( doctorDto.getName() );
}
if ( doctorDto.getAddress() != null ) {
if ( doctor.getAddress() == null ) {
doctor.setAddress( new Address() );
}
addressDtoToAddress(
doctorDto.getAddress(),
doctor.getAddress() );
}
if ( doctor.getSpeciality() == null ) {
doctor.setSpeciality( new Speciality() );
}
doctorDtoToSpeciality1( doctorDto, doctor.getSpeciality() );
}

Conclusion

This article described how to take advantage of the Mapstruct library to significantly reduce our boilerplate code in a safe and elegant way.

As seen in the examples, Mapstruct offers a vast set of functionalities and configurations which allows us to create from basic to complex mappers in an easy and fast way.

--

--