Get an estimate
Michał
2023/08/31
In applications based on a layered architecture, there is often a need to map data between objects that represent data models in the persistence layer (entities) and objects that serve as data models in the presentation layer or as data transferred to external systems (Data Transfer Object - DTO).
The process of mapping entities to DTOs and DTOs to entities can be complicated, especially for advanced data structures and complex relations between objects. Manual mapping would require the creation of repetitive code and would be time-consuming, which increases the risk of errors and makes the application more difficult to maintain.
Some time ago, an article about mapping complex objects using the ModelMapper library in Spring Boot Java appeared on our corporate blog. Today, I would like to present how to use another popular tool for this purpose, which is MapStruct.
The main goal of the MapStruct framework is to simplify the mapping process, by eliminating the need to write manual code to transform one type of object into another.
The way MapStruct works is that it automatically generates mapping code based on the defined mapping interfaces. All you need to do is to define an interface where you describe how to map fields between two classes, and then mark this interface with the @Mapper annotation. Once MapStruct is configured, the framework automatically generates an implementation of this interface that performs the mapping according to the rules defined.
One of the main advantages of this approach, and also an advantage over ModelMapper, whose mapping mechanisms use reflection, is performance. MapStruct also provides full type security. This means that if any data type incompatibilities occur, they will already be detected during compilation and not when the application is running. I also believe that the code created with this framework is more readable and easier to maintain.
Of course, simple mappings (when the types and field names are the same in the entity as in the DTO object) are no challenge whether we create our own mapping solutions or when we use tools designed for this. But what about when it gets complicated? Let’s look at the classes which Krzysztof mapped, in the article mentioned in the introduction.
@Entity@Datapublic class City { @Id @GeneratedValue(strategy = GenerationType.AUTO) private Long id; private String name; private Long population; @ToString.Exclude @EqualsAndHashCode.Exclude @OneToMany(cascade = CascadeType.ALL, mappedBy = "city") private Set buildings = new HashSet<>();}
@Entity@Datapublic class City {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
private Long id;
private String name;
private Long population;
@ToString.Exclude
@EqualsAndHashCode.Exclude
@OneToMany(cascade = CascadeType.ALL, mappedBy = "city")
private Set buildings = new HashSet<>();
@Entity@Datapublic class Building { @Id @GeneratedValue(strategy = GenerationType.AUTO) private Long id; private String name; private String number; private String address; @ToString.Exclude @EqualsAndHashCode.Exclude @OneToMany(cascade = CascadeType.ALL, mappedBy = "building") private Set rooms = new HashSet<>(); @ToString.Exclude @EqualsAndHashCode.Exclude @ManyToOne private City city; public void updateRelations() { rooms.forEach(room -> room.setBuilding(this)); }}
@Entity
@Datapublic class Building {
private String number;
private String address;
@OneToMany(cascade = CascadeType.ALL, mappedBy = "building")
private Set rooms = new HashSet<>();
@ManyToOne
private City city;
public void updateRelations() {
rooms.forEach(room -> room.setBuilding(this));
}
@Entity@Datapublic class Room { @Id @GeneratedValue(strategy = GenerationType.AUTO) private Long id; private String name; private Integer number; private Integer floor; @ToString.Exclude @EqualsAndHashCode.Exclude @OneToOne private Building building;}
@Entity@Datapublic class Room {
private Integer number;
private Integer floor;
@OneToOne
private Building building;
@Datapublic class BuildingDTO { private Long id; private String name; private String number; private String address; private Integer roomNumber; private List rooms = new ArrayList<>(); private String city;}
@Datapublic class BuildingDTO {
private Integer roomNumber;
private List rooms = new ArrayList<>();
private String city;
You can see that in BuildingDTO the field ‘city’ is of type String and in Building it is of type City. How can we map this in MapStruct? Let’s define the mappings in the BuildingMapper interface:
@Mapper(componentModel = "spring")public interface BuildingMapper { @Mapping(source = "city.name", target = "city") BuildingDTO toDto(Building entity);}
@Mapper(componentModel = "spring")public interface BuildingMapper {
BuildingDTO toDto(Building entity);
In order for a mapper implementation to be generated, the interface must be annotated with @Mapper (componentModel = “spring” means that we can use Spring-managed components). Fields that do not map directly can be defined in the @Mapping annotation above the mapping method declaration. In the ‘toDto()’ method, MapStruct will take the value from the source that is in the name field of the city object and assign it to the name field in the DTO. Simple, isn’t it? Well, but it doesn’t stop there. The BuildingDTO also contains a roomNumber field of type Integer, whose value we will get from the size of the rooms collection in the building object. Here we can use the decorator pattern, which is available in MapStruct with the @DecoratedWith annotation.
Firstly, let’s create a decorator class, which will be an abstract class implementing the mapper interface:
public abstract class BuildingMapperDecorator implements BuildingMapper { @Autowired @Qualifier("delegate") private BuildingMapper delegate; @Override public BuildingDTO toDto(Building building) { BuildingDTO buildingDTO = delegate.toDto(building); buildingDTO.setRoomNumber(building.getRooms().size()); return buildingDTO; }}
public abstract class BuildingMapperDecorator implements BuildingMapper { @Autowired @Qualifier("delegate")
private BuildingMapper delegate;
@Override
public BuildingDTO toDto(Building building) {
BuildingDTO buildingDTO = delegate.toDto(building);
buildingDTO.setRoomNumber(building.getRooms().size());
return buildingDTO;
In this class, we declare a field for mapping delegation, which we additionally mark by annotating @Qualifier(“delegate”), as recommended by the documentation. This is because two implementations will be generated- one for the mapper interface and one for the decorator. The first one is marked as “delegate”. Then, in the override method of the mapper method, we decorate our DTO objects, which in this case is the value of the number of rooms.
The given example may not be complicated, but it makes it clear what possibilities the decorator that is built into MapStruct offers. We can easily extend the decorator class, for example by injecting the needed dependencies, and decorate the object according to our requirements.