MapStruct — Are you still writing boilerplate code for Java Object mapping?

When writing a large enterprise application, we will always have to deal with multiple layers such as persistence, business logic and REST APIs. Those will most probably engage with different type of data objects in each layer — DTOs or Data Transfer Objects. Due to that, there can be many occasions that we need to convert one DTO from one layer into another similar DTO in another layer.

Let’s take an example:

Car.java

public class Car {
private String make;
private int seatCount;
private String type;

//getters and setters
}

CarDTO.java

public class CarDTO {

private String make;
private int seatCount;
private String type;
//getters and setters
}

Above two classes has the exactly the same attributes.

If we have a Car object and if we need to get a CarDTO object using the Car object values, we will need to write a mapper that gets each field of Car object and set them to CarDTO object.

I won’t write an example for that as all of us might have already gone through that pain!.

This is where Java Mapping Frameworks comes to the play. There are several mappers available like Dozer, Orika, MapStruct and JMapper. Please read through the article from Baeldung which does a very good analysis of them. In this article I am focusing on MapStruct which is one of the best performing and easy-to-use library.

Okey. Back to the topic; how MapStruct is going to make it easier to create a mapper for our earlier example?

  1. Create a Mapper Interface:

CarMapper.java

import org.mapstruct.Mapper;
import org.mapstruct.factory.Mappers;

@Mapper
public interface CarMapper {
CarMapper = Mappers.( CarMapper.class );

CarDTO carToCarDto(Car car);
}

2. Just use the above mapper!

public static void main(String[] args) {
Car car = new Car("2004", 4, "sedan");
CarDTO dto = CarMapper..carToCarDto(car);
System..println(dto);
}

We can see the output: CarDto{make=’2004', seatCount=4, type=’sedan’}

What did just happen.. How did it convert the Car object into CarDTO just with the Mapper interface without any implementation?

The official MapStruct site says:

To see whether above statements are true, lets have a look at the target folder after our application is built.

MapStruct — generates the implementation for the Mapper interface
@Generated(
value = "org.mapstruct.ap.MappingProcessor",
date = "2020-10-31T14:27:09+0530",
comments = "version: 1.4.1.Final, compiler: javac, environment: Java 1.8.0_231 (Oracle Corporation)"
)
public class CarMapperImpl implements CarMapper {

@Override
public CarDTO carToCarDto(Car car) {
if ( car == null ) {
return null;
}

CarDTO carDTO = new CarDTO();

carDTO.setMake( car.getMake() );
carDTO.setSeatCount( car.getSeatCount() );
carDTO.setType( car.getType() );

return carDTO;
}
}

That is cool! It has generated an implementation for the CarMapper Interface we provided.

However, in most of the situations, mapping objects are not straightforward as above; where Car and CarDTO has the same named and typed fields. Lets make this slightly complicated.

When some of the field names are different:

Consider that we are changing make to yearOfMake in CarDTO.

public class CarDTO {

private String yearOfMake;
private int seatCount;
private String type;
//getters and setters
}

The change we need to do in the interface is adding a Mapping annotation for the changed attribute.

@Mapper
public interface CarMapper {
CarMapper = Mappers.( CarMapper.class );

@Mapping(source = "make", target = "yearOfMake")
CarDTO carToCarDto(Car car);
}

Dealing with data type changes that are slightly complex:

  1. Define a Passenger Object
public class Passenger {
private int age;
private String name;
// getters and setters
}

2. Add a list of Passenger objects to the Car.

public class Car {
private String make;
private int seatCount;
private String type;
private List<Passenger> passengers;
// getters and setters
}

3. Add a list of String objects to the CarDTO. This is to represent only the names of the passengers.

public class CarDTO {
private String yearOfMake;
private int seatCount;
private String type;
private List<String> passengersInCar = new ArrayList<>();
// getters and setters
}

Now our challenge is to map the List of Passenger objects List<Passenger> passenger to a List of Strings List<String> passengersInCar

Our new Mapper interface would look like:

@Mapper
public interface CarMapper {
CarMapper = Mappers.( CarMapper.class );

@Mapping(source = "make", target = "yearOfMake")
@Mapping(source = "passengers", target = "passengersInCar")
CarDTO carToCarDto(Car car);

default String passengerToString(Passenger passenger) {
return passenger.getName();
}
}

Here we defined a method that takes aPassenger object as an input and returns a String as output. MapStruct uses this method for the type conversion of Passenger to String.

Below is the new Mapper implementation MapStruct generates for us.

@Generated(
value = "org.mapstruct.ap.MappingProcessor",
date = "2020-10-31T15:00:31+0530",
comments = "version: 1.4.1.Final, compiler: javac, environment: Java 1.8.0_231 (Oracle Corporation)"
)
public class CarMapperImpl implements CarMapper {

@Override
public CarDTO carToCarDto(Car car) {
if ( car == null ) {
return null;
}

CarDTO carDTO = new CarDTO();

carDTO.setYearOfMake( car.getMake() );
carDTO.setPassengersInCar( passengerListToStringList( car.getPassengers() ) );
carDTO.setSeatCount( car.getSeatCount() );
carDTO.setType( car.getType() );

return carDTO;
}

protected List<String> passengerListToStringList(List<Passenger> list) {
if ( list == null ) {
return null;
}

List<String> list1 = new ArrayList<String>( list.size() );
for ( Passenger passenger : list ) {
list1.add( passengerToString( passenger ) );
}

return list1;
}
}

Making compile time failures when a field in the target or source is not mapped

What happens when someone accidentally changed a field into a different name? Let’s say someone refactored Car and changed type into carType. Now the conversion output after the change would look like below which will give type as null

CarDto{make=’2004', seatCount=4, type=’null’, passengersInCar=[john, henry]}

This might not be acceptable because we don’t see this issue until we run the application. It can at some point give NPEs because it used to give a value before and there might be code which uses it already. It is important to detect issues like this before and ideally during the compilation time.

Fortunately this is also possible. We need to add a configuration to our Mapper which defines unmappedTargetPolicy.

@Mapper(unmappedTargetPolicy = ReportingPolicy.)
public interface CarMapper {
...
}

If we set it to ERROR mode, the compiler will produce an error whenever it founds an unmapped property in the target DTO.

Mapstruct — compilation failure when using unmappedTargetPolicy as ERROR

Such error situations can be detected by compilation time itself now. Similarly we can use unmappedSourcePolicy as well.

Here we discussed some of the important features in MapStruct which are commonly useful. The full list of features with details can be found in the MapStruct official website.

The examples used for this writing is available at my GitHub Repo mapstruct-example.

Happy Coding!

Technical Lead - WSO2 Inc.