JavaRush /Java Blog /Random EN /What is Mapstruct and how to properly configure it for un...

What is Mapstruct and how to properly configure it for unit testing in SpringBoot applications

Published in the Random EN group

Background

Hello everyone, my dear friends and readers! Before we write the article, a little background... I recently encountered a problem working with the Mapstruct library , which I briefly described in my telegram channel here . In the comments, the problem with the post was solved; my colleague from the previous project helped with this. What is Mapstruct and how to properly configure it for unit testing in SpringBoot applications.  Part 1 - 1After that, I decided to write an article on this topic, but of course we will not take a narrow view and will first try to get up to speed, understand what Mapstruct is and why it is needed, and using a real example we will analyze the situation that arose earlier and how to solve it. Therefore, I strongly recommend doing all the calculations in parallel with reading the article in order to experience everything in practice. Before we start, subscribe to my telegram channel , I collect my activities there, write thoughts about development in Java and IT in general. Subscribed? Great! Well, now let's go!

Mapstruct, faq?

A code generator for fast type-safe bean mappings. Our first task is to figure out what Mapstruct is and why we need it. In general, you can read about it on the official website. On the main page of the site there are three answers to the questions: what is it? For what? How? We will try to do this too:

What it is?

Mapstruct is a library that helps to map (map, in general, that’s what they always say: map, map, etc.) objects of some entities into objects of other entities using generated code based on configurations that are described through interfaces.

For what?

For the most part, we develop multi-layer applications (a layer for working with the database, a layer of business logic, a layer for interaction of the application with the outside world) and each layer has its own objects for storing and processing data. And this data needs to be transferred from layer to layer by transferring from one entity to another. For those who haven't worked with this approach, this may seem a bit complicated. For example, we have an entity for the Student database. When the data of this entity goes to the business logic (services) layer, we need to transfer the data from the Student class to the StudentModel class. Next, after all the manipulations with the business logic, the data needs to be released outside. And for this we have the StudentDto class. Of course, we need to pass data from the StudentModel class to StudentDto. Writing by hand each time the methods that will be transferred is labor-intensive. Plus this is extra code in the code base that needs to be maintained. You can make a mistake. And Mapstruct generates such methods at the compilation stage and stores them in generated-sources.

How?

Using annotations. We just need to create an annotation that has a main Mapper annotation that tells the library that the methods in this interface can be used to translate from one object to another. As I said earlier about students, in our case this will be the StudentMapper interface, which will have several methods for transferring data from one layer to another:

public class Student {
   private Long id;
   private String firstName;
   private String lastName;
   private Integer age;
}

public class StudentDTO {
   private Long id;
   private String firstName;
   private String lastName;
   private Integer age;
}

public class StudentModel {
   private Long id;
   private String firstName;
   private String lastName;
   private Integer age;
}
For these classes we create a mapper (hereinafter this is what we will call the interface, which describes what we want to transfer and where):

@Mapper
public interface StudentMapper {
   StudentModel toModel(StudentDTO dto);
   Student toEntity(StudentModel model);
   StudentModel toModel(Student entity);
   StudentDTO toDto(StudentModel model);
}
The beauty of this approach is that if the names and types of fields are the same in different classes (as in our case), then the settings for Mapstruct are enough to generate the necessary implementation based on the StudentMapper interface at the compilation stage, which will translate. So it’s already become clearer, right? Let's go further and use a real example to analyze the work in a Spring Boot application.

An example of Spring Boot and Mapstruct working

The first thing we need is to create a Spring Boot project and add Mapstruct to it. For this matter, I have an organization in GitHub with templates for repositories and a start for Spring Boot is one of them. Based on it, we create a new project: What is Mapstruct and how to properly configure it for unit testing in SpringBoot applications.  Part 1 - 2Next, we get the project . Yes, friends, give the project a star if you found it useful, so I will know that I am not doing this in vain. In this project, we will reveal a situation that I received at work and described in a post on my Telegram channel . I’ll briefly outline the situation for those who are not in the know: when we write tests for mappers (that is, for those interface implementations that we talked about earlier), we want the tests to pass as quickly as possible. The simplest option with mappers is to use the SpringBootTest annotation when running the test, which will pick up the entire ApplicationContext of the Spring Boot application and inject the mapper needed for the test inside the test. But this option is resource-intensive and takes much more time, so it is not suitable for us. We need to write a unit test that simply creates the desired mapper and checks that its methods work exactly as we expect. Why do you need tests to run faster? If tests take a long time, it slows down the entire development process. Until the tests pass on the new code, this code cannot be considered correct and will not be taken for testing, which means it will not be taken into production and which means that the developer has not completed the work. It would seem, why write a test for a library whose operation is beyond doubt? And yet we need to write a test, because we are testing how correctly we described the mapper and whether it does what we expect. First of all, to make our work easier, let's add Lombok to our project by adding another dependency to pom.xml:

<dependency>
   <groupId>org.projectlombok</groupId>
   <artifactId>lombok</artifactId>
   <scope>provided</scope>
</dependency>
In our project, we will need to transfer from model classes (which are used to work with business logic) to DTO classes, which we use to communicate with the outside world. In our simplified version, we will assume that the fields do not change and our mappers will be simple. But, if there is a desire, it would be possible to write a more detailed article on how to work with Mapstruct, how to configure it, and how to take advantage of its benefits. But then, since this article will be quite long. Let's say we have a student with a list of lectures and lecturers that he attends. Let's create a model package . Based on this, we will create a simple model:

package com.github.romankh3.templaterepository.springboot.dto;

import lombok.Data;

import java.util.List;

@Data
public class StudentDTO {

   private Long id;

   private String name;

   private List<LectureDTO> lectures;

   private List<LecturerDTO> lecturers;
}
his lectures

package com.github.romankh3.templaterepository.springboot.dto;

import lombok.Data;

@Data
public class LectureDTO {

   private Long id;

   private String name;
}
and lecturers

package com.github.romankh3.templaterepository.springboot.dto;

import lombok.Data;

@Data
public class LecturerDTO {

   private Long id;

   private String name;
}
And create a dto package next to the model package :

package com.github.romankh3.templaterepository.springboot.dto;

import lombok.Data;

import java.util.List;

@Data
public class StudentDTO {

   private Long id;

   private String name;

   private List<LectureDTO> lectures;

   private List<LecturerDTO> lecturers;
}
lectures

package com.github.romankh3.templaterepository.springboot.dto;

import lombok.Data;

@Data
public class LectureDTO {

   private Long id;

   private String name;
}
and lecturers

package com.github.romankh3.templaterepository.springboot.dto;

import lombok.Data;

@Data
public class LecturerDTO {

   private Long id;

   private String name;
}
Now let's create a mapper that will translate a collection of lecture models into a collection of DTO lectures. The first thing to do is add Mapstruct to the project. To do this, we will use their official website , everything is described there. That is, we need to add one dependency and a plugin to our memory (if you have questions about what a memory is, here you go, Article1 and Article2 ):

<dependency>
   <groupId>org.mapstruct</groupId>
   <artifactId>mapstruct</artifactId>
   <version>1.4.2.Final</version>
</dependency>
and in the memory in the <build/> block. which we haven't had yet:

<build>
   <plugins>
       <plugin>
           <groupId>org.apache.maven.plugins</groupId>
           <artifactId>maven-compiler-plugin</artifactId>
           <version>3.5.1</version>
           <configuration>
               <source>1.8</source>
               <target>1.8</target>
               <annotationProcessorPaths>
                   <path>
                       <groupId>org.mapstruct</groupId>
                       <artifactId>mapstruct-processor</artifactId>
                       <version>1.4.2.Final</version>
                   </path>
               </annotationProcessorPaths>
           </configuration>
       </plugin>
   </plugins>
</build>
Next, let's create a mapper package next to dto and model . Based on the classes that we showed earlier, you will need to create five more mappers:
  • Mapper LectureModel <-> LectureDTO
  • Mapper List<LectureModel> <-> List<LectureDTO>
  • Mapper LecturerModel <-> LecturerDTO
  • Mapper List<LecturerModel> <-> List<LecturerDTO>
  • Mapper StudentModel <-> StudentDTO
Go:

LectureMapper


package com.github.romankh3.templaterepository.springboot.mapper;

import com.github.romankh3.templaterepository.springboot.dto.LectureDTO;
import com.github.romankh3.templaterepository.springboot.dto.LecturerDTO;
import com.github.romankh3.templaterepository.springboot.model.LectureModel;
import org.mapstruct.Mapper;

@Mapper(componentModel = "spring")
public interface LectureMapper {
   LectureDTO toDTO(LectureModel model);

   LectureModel toModel(LecturerDTO dto);
}

LectureListMapper


package com.github.romankh3.templaterepository.springboot.mapper;

import com.github.romankh3.templaterepository.springboot.dto.LectureDTO;
import com.github.romankh3.templaterepository.springboot.dto.LectureDTO;
import com.github.romankh3.templaterepository.springboot.model.LectureModel;
import org.mapstruct.Mapper;

import java.util.List;

@Mapper(componentModel = "spring", uses = LectureMapper.class)
public interface LectureListMapper {
   List<LectureModel> toModelList(List<LectureDTO> dtos);
   List<LectureDTO> toDTOList(List<LectureModel> models);
}

LecturerMapper


package com.github.romankh3.templaterepository.springboot.mapper;

import com.github.romankh3.templaterepository.springboot.dto.LectureDTO;
import com.github.romankh3.templaterepository.springboot.model.LectureModel;
import org.mapstruct.Mapper;

@Mapper(componentModel = "spring")
public interface LectureMapper {
   LectureDTO toDTO(LectureModel model);

   LectureModel toModel(LectureDTO dto);
}

LecturerListMapper


package com.github.romankh3.templaterepository.springboot.mapper;

import com.github.romankh3.templaterepository.springboot.dto.LecturerDTO;
import com.github.romankh3.templaterepository.springboot.model.LecturerModel;
import org.mapstruct.Mapper;

import java.util.List;

@Mapper(componentModel = "spring", uses = LecturerMapper.class)
public interface LecturerListMapper {
   List<LecturerModel> toModelList(List<LecturerDTO> dloList);
   List<LecturerDTO> toDTOList(List<LecturerModel> modelList);
}

StudentMapper


package com.github.romankh3.templaterepository.springboot.mapper;

import com.github.romankh3.templaterepository.springboot.dto.StudentDTO;
import com.github.romankh3.templaterepository.springboot.model.StudentModel;
import org.mapstruct.Mapper;

@Mapper(componentModel = "spring", uses = {LectureListMapper.class, LecturerListMapper.class})
public interface StudentMapper {
   StudentDTO toDTO(StudentModel model);
   StudentModel toModel(StudentDTO dto);
}
It should be noted separately that in mappers we refer to other mappers. This is done through the uses field in the Mapper annotation, as is done in StudentMapper:

@Mapper(componentModel = "spring", uses = {LectureListMapper.class, LecturerListMapper.class})
Here we use two mappers to correctly map the list of lectures and the list of lecturers. Now we need to compile our code and see what is there and how. This can be done using the mvn clean compile command . But, as it turned out, when creating Mapstruct implementations of our mappers, the mapper implementations did not overwrite the fields. Why? It turned out that it was not possible to pick up the Data annotation from Lombok. And something had to be done... Therefore, we have a new section in the article.

Linking Lombok and Mapstruct

After a few minutes of searching, it turned out that we needed to connect Lombok and Mapstruct in a certain way. There is information about this in the Mapstruct documentation . After examining the example proposed by the developers from Mapstruct, let's update our pom.xml: Let's add separate versions:

​​<properties>
   <org.mapstruct.version>1.4.2.Final</org.mapstruct.version>
   <lombok-mapstruct-binding.version>0.2.0</lombok-mapstruct-binding.version>
</properties>
Let's add the missing dependency:

<dependency>
   <groupId>org.projectlombok</groupId>
   <artifactId>lombok-mapstruct-binding</artifactId>
   <version>${lombok-mapstruct-binding.version}</version>
</dependency>
And let’s update our compiler plugin so that it can connect Lombok and Mapstruct:

<plugin>
   <groupId>org.apache.maven.plugins</groupId>
   <artifactId>maven-compiler-plugin</artifactId>
   <version>3.5.1</version>
   <configuration>
       <source>1.8</source>
       <target>1.8</target>
       <annotationProcessorPaths>
           <path>
               <groupId>org.mapstruct</groupId>
               <artifactId>mapstruct-processor</artifactId>
               <version>${org.mapstruct.version}</version>
           </path>
           <path>
               <groupId>org.projectlombok</groupId>
               <artifactId>lombok</artifactId>
               <version>${lombok.version}</version>
           </path>
           <path>
               <groupId>org.projectlombok</groupId>
               <artifactId>lombok-mapstruct-binding</artifactId>
               <version>${lombok-mapstruct-binding.version}</version>
           </path>
       </annotationProcessorPaths>
   </configuration>
</plugin>
After this everything should work out. Let's compile our project again. But where can you find the classes that Mapstruct generated? They are in generated-sources: ${projectDir}/target/generated-sources/annotations/ What is Mapstruct and how to properly configure it for unit testing in SpringBoot applications.  Part 1 - 3 Now that we are prepared to realize my disappointment from the Mapstruct post, let's try to create tests for mappers.

We write tests for our mappers

I'll create a quick and simple test that would test one of the mappers in the case where we are creating an integration test and don't worry about its completion time:

LectureMapperTest


package com.github.romankh3.templaterepository.springboot.mapper;

import com.github.romankh3.templaterepository.springboot.dto.LectureDTO;
import com.github.romankh3.templaterepository.springboot.model.LectureModel;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

@SpringBootTest
class LectureMapperTest {

   @Autowired
   private LectureMapper mapperUnderTest;

   @Test
   void shouldProperlyMapModelToDto() {
       //given
       LectureModel model = new LectureModel();
       model.setId(11L);
       model.setName("lecture name");

       //when
       LectureDTO dto = mapperUnderTest.toDTO(model);

       //then
       Assertions.assertNotNull(dto);
       Assertions.assertEquals(model.getId(), dto.getId());
       Assertions.assertEquals(model.getName(), dto.getName());
   }

   @Test
   void shouldProperlyMapDtoToModel() {
       //given
       LectureDTO dto = new LectureDTO();
       dto.setId(11L);
       dto.setName("lecture name");

       //when
       LectureModel model = mapperUnderTest.toModel(dto);

       //then
       Assertions.assertNotNull(model);
       Assertions.assertEquals(dto.getId(), model.getId());
       Assertions.assertEquals(dto.getName(), model.getName());
   }
}
Here, using the SpringBootTest annotation, we launch the entire applicationContext and from it, using the Autowired annotation, we extract the class we need for testing. From the point of view of speed and ease of writing a test, this is very good. The test passes successfully, everything is fine. But we’ll go the other way and write a unit test for a mapper, for example, LectureListMapper...

package com.github.romankh3.templaterepository.springboot.mapper;

import com.github.romankh3.templaterepository.springboot.dto.LectureDTO;
import com.github.romankh3.templaterepository.springboot.model.LectureModel;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;

import java.util.Collections;
import java.util.List;

class LectureListMapperTest {

   private final LectureListMapper lectureListMapper = new LectureListMapperImpl();

   @Test
   void shouldProperlyMapListDtosToListModels() {
       //given
       LectureDTO dto = new LectureDTO();
       dto.setId(12L);
       dto.setName("I'm BATMAN!");

       List<LectureDTO> dtos = Collections.singletonList(dto);

       //when
       List<LectureModel> models = lectureListMapper.toModelList(dtos);

       //then
       Assertions.assertNotNull(models);
       Assertions.assertEquals(1, models.size());
       Assertions.assertEquals(dto.getId(), models.get(0).getId());
       Assertions.assertEquals(dto.getName(), models.get(0).getName());
   }
}
Since the implementations that Mapstruct generates are in the same class as our project, we can easily use them in our tests. Everything looks great - no annotations, we create the class that we need in the simplest way and that’s it. But when we run the test, we will understand that it will crash and there will be a NullPointerException in the console... This is because the implementation of the LectureListMapper mapper looks like:

package com.github.romankh3.templaterepository.springboot.mapper;

import com.github.romankh3.templaterepository.springboot.dto.LectureDTO;
import com.github.romankh3.templaterepository.springboot.model.LectureModel;
import java.util.ArrayList;
import java.util.List;
import javax.annotation.Generated;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;

@Generated(
   value = "org.mapstruct.ap.MappingProcessor",
   date = "2021-12-09T21:46:12+0300",
   comments = "version: 1.4.2.Final, compiler: javac, environment: Java 15.0.2 (N/A)"
)
@Component
public class LectureListMapperImpl implements LectureListMapper {

   @Autowired
   private LectureMapper lectureMapper;

   @Override
   public List<LectureModel> toModelList(List<LectureDTO> dtos) {
       if ( dtos == null ) {
           return null;
       }

       List<LectureModel> list = new ArrayList<LectureModel>( dtos.size() );
       for ( LectureDTO lectureDTO : dtos ) {
           list.add( lectureMapper.toModel( lectureDTO ) );
       }

       return list;
   }

   @Override
   public List<LectureDTO> toDTOList(List<LectureModel> models) {
       if ( models == null ) {
           return null;
       }

       List<LectureDTO> list = new ArrayList<LectureDTO>( models.size() );
       for ( LectureModel lectureModel : models ) {
           list.add( lectureMapper.toDTO( lectureModel ) );
       }

       return list;
   }
}
If we look at the NPE (short for NullPointerException), we get it from the lectureMapper variable , which turns out to be not initialized. But in our implementation we do not have a constructor with which we could initialize the variable. This is exactly the reason why Mapstruct implemented the mapper this way! In Spring, you can add beans to classes in several ways, you can inject them through a field along with the Autowired annotation, as done above, or you can inject them through a constructor. I found myself in such a problematic situation at work when I needed to optimize test execution time. I thought that nothing could be done about it and poured out my pain on my Telegram channel. And then they helped me in the comments and said that it was possible to customize the injection strategy. The Mapper interface has an injectionStrategy field , which just accepts the InjectionStrategy name , which has two values: FIELD and CONSTRUCTOR . Now, knowing this, let’s add this setting to our mappers; I’ll show it using LectureListMapper as an example :

@Mapper(componentModel = "spring", uses = LectureMapper.class, injectionStrategy = InjectionStrategy.CONSTRUCTOR)
public interface LectureListMapper {
   List<LectureModel> toModelList(List<LectureDTO> dtos);
   List<LectureDTO> toDTOList(List<LectureModel> models);
}
I highlighted in bold the part I added. Let's add this option for all the others and recompile the project so that mappers are generated with a new line. When we do this, let's see how the implementation of the mapper for LectureListMapper has changed (highlighted in bold the part that we need):

package com.github.romankh3.templaterepository.springboot.mapper;

import com.github.romankh3.templaterepository.springboot.dto.LectureDTO;
import com.github.romankh3.templaterepository.springboot.model.LectureModel;
import java.util.ArrayList;
import java.util.List;
import javax.annotation.Generated;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;

@Generated(
   value = "org.mapstruct.ap.MappingProcessor",
   date = "2021-12-09T22:25:37+0300",
   comments = "version: 1.4.2.Final, compiler: javac, environment: Java 15.0.2 (N/A)"
)
@Component
public class LectureListMapperImpl implements LectureListMapper {

   private final LectureMapper lectureMapper;

   @Autowired
   public LectureListMapperImpl(LectureMapper lectureMapper) {

       this.lectureMapper = lectureMapper;
   }

   @Override
   public List<LectureModel> toModelList(List<LectureDTO> dtos) {
       if ( dtos == null ) {
           return null;
       }

       List<LectureModel> list = new ArrayList<LectureModel>( dtos.size() );
       for ( LectureDTO lectureDTO : dtos ) {
           list.add( lectureMapper.toModel( lectureDTO ) );
       }

       return list;
   }

   @Override
   public List<LectureDTO> toDTOList(List<LectureModel> models) {
       if ( models == null ) {
           return null;
       }

       List<LectureDTO> list = new ArrayList<LectureDTO>( models.size() );
       for ( LectureModel lectureModel : models ) {
           list.add( lectureMapper.toDTO( lectureModel ) );
       }

       return list;
   }
}
And now Mapstruct has implemented mapper injection through the constructor. This is exactly what we were trying to achieve. Now our test will stop compiling, let's update it and get:

package com.github.romankh3.templaterepository.springboot.mapper;

import com.github.romankh3.templaterepository.springboot.dto.LectureDTO;
import com.github.romankh3.templaterepository.springboot.model.LectureModel;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;

import java.util.Collections;
import java.util.List;

class LectureListMapperTest {

   private final LectureListMapper lectureListMapper = new LectureListMapperImpl(new LectureMapperImpl());

   @Test
   void shouldProperlyMapListDtosToListModels() {
       //given
       LectureDTO dto = new LectureDTO();
       dto.setId(12L);
       dto.setName("I'm BATMAN!");

       List<LectureDTO> dtos = Collections.singletonList(dto);

       //when
       List<LectureModel> models = lectureListMapper.toModelList(dtos);

       //then
       Assertions.assertNotNull(models);
       Assertions.assertEquals(1, models.size());
       Assertions.assertEquals(dto.getId(), models.get(0).getId());
       Assertions.assertEquals(dto.getName(), models.get(0).getName());
   }
}
Now, if we run the test, everything will work as expected, since in LectureListMapperImpl we pass the LectureMapper it needs... Victory! It’s not difficult for you, but I’m pleased: Friends, everything is as usual, subscribe to my GitHub account , to my Telegram account . There I post the results of my activities, there are really useful things) I especially invite you to join the discussion group of the telegram channel . It so happens that if someone has a technical question, they can get an answer there. This format is interesting for everyone, you can read who knows what and gain experience.

Conclusion

As part of this article, we got acquainted with such a necessary and frequently used product as Mapstruct. We figured out what it is, why and how. Using a real example, we felt what could be done and how it could be changed. We also looked at how to set up the injection of beans through the constructor, so that it would be possible to properly test mappers. Colleagues from Mapstruct allowed users of their product to choose exactly how to inject mappers, for which we undoubtedly thank them. BUT, despite the fact that Spring recommends injecting beans through the constructor, the guys from Mapstruct have set injection through the field by default. Why is that? No answer. I suspect there may be reasons that we just don't know about, and that's why they did it this way. And to find out from them, I created a GitHub issue on their official product repository.
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION