
Introduction:
MapStruct is a powerful Java-based mapping framework that simplifies the process of mapping between Java bean types. When combined with Spring Boot, it offers a seamless integration for data mapping tasks in enterprise applications. In this blog post, we’ll delve into how to master data mapping using MapStruct and integrate it seamlessly with Spring Boot.
1. Installation
Add MapStruct Dependencies: First, you need to add the MapStruct dependencies to your project. Include the MapStruct dependency and the MapStruct processor as dependencies in your build configuration.
Gradle:
dependencies {
implementation 'org.mapstruct:mapstruct:1.4.2.Final'
annotationProcessor 'org.mapstruct:mapstruct-processor:1.4.2.Final'
}
Maven:
<dependencies>
<dependency>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct</artifactId>
<version>1.4.2.Final</version>
</dependency> </dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<source>11</source>
<target>11</target>
<annotationProcessorPaths>
<path>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct-processor</artifactId>
<version>1.4.2.Final</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
</plugins>
</build>
2. Configuration
Configure MapStruct: MapStruct does not require any specific configuration. You just need to ensure that the MapStruct annotation processor is enabled in your build configuration, as shown in the Gradle and Maven examples above.
3. The scenarios to use MapStruct
Every time we need to convert between DTO (Data Transfer Object) objects to another DTO, we often have to write boilerplate code to copy data from one object to another. This can lead to errors if data fields are forgotten to be copied or assigned incorrectly.
Now let’s go through specific scenarios where MapStruct should be applied.
3.1. Using The Mapper Interface and triggering generation of implementation class:
The first scenario, we have 2 objects ExampleSource and ExampleDestination with identical fields and want to copy data from ExampleSource to ExampleDestination quickly and without having to create a function to copy for each field, we consider using The Mapper Interface:
public class ExampleSource {
private String name;
private String description;
// getters and setters
}
public class ExampleDestination {
private String name;
private String description;
// getters and setters
}
@Mapper
public interface ExampleMapper {
ExampleDestination sourceToDestination(ExampleSource source);
ExampleSource destinationToSource(ExampleDestination destination);
}
We did not create an implementation class for ExampleSourceDestinationMapper, because MapStruct creates it.
Executing an ‘mvn clean install’ with Maven or ‘gradle build’ with Gradle to trigger the MapStruct processing. This will generate the implementation class under /target/generated-sources/annotations/.
Here is the class that MapStruct auto generated for us:
public class ExampleMapperImpl implements ExampleMapper {
@Override
public ExampleDestination sourceToDestination(ExampleSource source) {
if (source == null) {
return null;
}
ExampleDestination ExampleDestination = new ExampleDestination();
exampleDestination.setName(source.getName());
exampleDestination.setDescription(source.getDescription());
return exampleDestination;
}
@Override
public ExampleSource destinationToSource(ExampleDestination destination){
if (destination == null) {
return null;
}
ExampleSource exampleSource = new ExampleSource();
exampleSource.setName(destination.getName());
exampleSource.setDescription(destination.getDescription());
return exampleSource;
}
}
3.2. Mapping with Dependency Injection
In the next scenario, we have a field ‘name’ in the ExampleSource object that needs to be processed in ExampleService before setting it for the ‘name’ field in ExampleDestination.
We have to use an abstract class instead of an interface:
@Mapper(componentModel = "spring")
public abstract class ExampleMapper {
@Autowired
protected ExampleService exampleService;
@Mapping(target = "name", expression = "java(exampleService.enrichName(source.getName()))")
public abstract ExampleDestination sourceToDestination(ExampleSource source);
}
3.3. Mapping Fields with Different Field Names
In the next scenario, the two objects ExampleSource and ExampleDestination have different field names
We will need to configure its source field to its target field and to do that, We will need to add @Mapping annotation for each field.
In MapStruct, we can also use dot notation to define a member of a bean:
public class ExampleSource {
private String name;
private String description;
// getters and setters
}
public class ExampleDestination {
private String destName;
private String destDescription;
// getters and setters
}
@Mapper
public interface ExampleMapper {
@Mapping(target = "destName", source = "source.name")
@Mapping(target = "destDescription", source = "source.description")
ExampleDestination sourceToDestination(ExampleSource source);
@Mapping(target = "name", source = "dest.destName")
@Mapping(target = "description", source = "dest.destDescription")
ExampleSource destinationToSource(ExampleDestination dest);
}
3.4. Mapping Beans with Child Beans
Next, let’s assume we have a child object named childExample within ExampleSource and ExampleDestination.
public class ExampleSource {
private String name;
private String description;
private ChildExampleSource childExample;
// getters and setters
}
public class ExampleDestination {
private String destName;
private String destDescription;
private ChildExampleDestination childExample;
// getters and setters
}
public class ChildExampleSource {
private int id;
private String name;
// default constructor, getters and setters committed
}
public class ChildExampleDestination {
private int id;
private String name;
// default constructor, getters and setters committed
}
Adding a method to convert the ChildExampleSource to ChildExampleDestination and vice versa.
If MapStruct detects that the object type needs to be converted and the method to convert exists in the same class, it will use it automatically.
ChildExampleDestination childExampleSourceToChildExampleDestination(ChildExampleSource source);
ChildExampleSource childExampleDestinationtoChildExampleSource(ChildExampleDestination dest);
3.5. Mapping With Type Conversion
In this scenario, we want to convert from String to Date, we consider using implicit type conversion.
public class ExampleSource {
// other fields
private Date startDt;
// getters and setters
}
public class ExampleDestination {
// other fields
private String destStartDt;
// getters and setters
}
@Mapping(target="userStartDt", source = "source.startDt", dateFormat = "dd-MM-yyyy HH:mm:ss")
ExampleDestination sourceToDestination(ExampleSource source);
@Mapping(target="startDt", source="dest.destStartDt", dateFormat="dd-MM-yyyy HH:mm:ss")
ExampleSource destinationToSource(ExampleDestination dest);
3.6. Mapping with an Abstract Class
In the next scenario, we have a ‘description’ field in the ExampleSource object and want to prepend a string of ‘New: ‘ before setting the value of the ‘description’ field in ExampleDestination.
We can create an abstract class and implement methods we want to have customized and leave abstract those that should be generated by MapStruct.
@Mapper
abstract class ExampleMapper {
public ExampleDestination toExampleDestination(ExampleSource source){
ExampleDestination dest = new ExampleDestination();
dest.setName(source.getName());
dest.setDescription("New: " + source.getDescription());
return dest;
}
public abstract List<ExampleDestination> toExampleDestinations(Collection<ExampleSource> exampleSources);
}
The source code will be generated:
@Generated
class ExampleMapperImpl extends ExampleMapper {
@Override
public List<ExampleDestination> toExampleDestinations(Collection<ExampleSource> exampleSources) {
if (exampleSources == null) {
return null;
}
List<ExampleDestination> list = new ArrayList<>();
for (ExampleSource source : exampleSources) {
list.add(toExampleDestination(source));
}
return list;
}
}
3.7. Using Before-Mapping and After-Mapping Annotations
For a scenario, we include methods called immediately before and after the mapping logic. We consider using the annotations @BeforeMapping and @AfterMapping.
@Mapper
public abstract class ExampleMapper {
@BeforeMapping
protected void setNoName(ExampleSource source, @MappingTarget ExampleDestination dest){
if (source.getName() === null) {
dest.setName("No name").
}
}
@AfterMapping
protected void convertNameToUpperCase(@MappingTarget ExampleDestination dest) {
dest.setName(dest.getName().toUpperCase()).
}
public abstract ExampleDestination toExampleDestination(ExampleSource source).
}
The source code will be generated:
@Generated
public class ExampleMapperImpl extends ExampleMapper {
@Override
public ExampleDestination toExampleDestination(ExampleSource source) {
if (source == null) {
return null;
}
ExampleDestination dest = new ExampleDestination();
setNoName(source, dest);
dest.setDescription(source.getDescription());
convertNameToUpperCase(dest);
return dest;
}
}
3.8. Ignoring a Field
In this scenario, when we need to ignore a field, for which we use the ignore property.
@Mapper(componentModel = "spring")
public interface ExampleMapper {
@Mapping(ignore = true, target = "description")
ExampleDestination sourceToDestination(ExampleSource source);
@Mapping(ignore = true, target = "description")
ExampleSource destinationToSource(ExampleDestination dest);
}
3.9. Using @Named to create a custom mapper
Similar to the scenario in section 3.6 we have a ‘description’ field at ExampleSource and want to prepend a string of ‘New: ‘ then set the value of the ‘description’ field at ExampleDestination, We can use @Named. Let’s see an example.
@Mapper
public interface ExampleMapper {
@Mapping(source = "source.description", target = "description", qualifiedByName = "addStringToDescription")
ExampleDestination toDomains(ExampleSource source);
@Named("addStringToDescription")
public static double addStringToDescription(String description) {
return "New: " + description;
}
}
4. Advantages and Disadvantages of MapStruct
From the scenarios above, we can observe the advantages and disadvantages of using MapStruct as follows:
Advantages:
- Ease of Use: MapStruct provides a straightforward way to create mapping through simple annotations, reducing the need for manual code writing.
- Good Performance: Since MapStruct generates Java code at compile time, there is no runtime overhead, which helps to improve performance compared to using other mapping libraries like ModelMapper.
- Optimized Control: By generating code at compile time, MapStruct allows for full control over the mapping process, helping to avoid common data mapping-related errors.
- Customization Support: MapStruct allows for customization of mapping through custom methods, enabling handling of complex or special cases without the need for verbose code.
Disadvantages:
- Limited Customization Ability: Although MapStruct can be customized, achieving this may be more challenging in some cases compared to using more flexible mapping libraries like Dozer.
- Difficulty Handling Complex Cases: While MapStruct supports customization, handling complex cases such as mapping between complex data structures can make the code harder to understand and maintain.
5. Conclusion:
By mastering the integration of MapStruct with Spring Boot, you’ll be equipped with powerful tools for simplifying data mapping within your Java applications. This guide will empower you to efficiently handle complex mapping scenarios and build robust, maintainable Spring Boot applications.
References: