Migrating from ModelMapper to Map Struct

Posted on December 1, 2020
Tags: java, programming

TL;DR

I’ve built many applications that have used ModelMapper for projecting from one object structure to another, and it has been very good for getting up and running quickly. However, it costs in terms of type safety, and startup performance. So, I’ve switched to a type safe, compile time alternative MapStruct.

This article mainly covers the pros and cons of each framework, not how to use them in real projects, or best practises. There are plenty of other articles on the web covering these topics.

Background

ModelMapper is a Java library for projecting from one object structure to another. For instance, from database objects onto DTOs. It is very easy to get started, just create an instance, of the class. And, out of the box it has a very flexible matching strategy that allows for object structures to be expanded or collapsed. An example from the website,

Source model

// Assume getters and setters on each class
class Order {
  Customer customer;
  Address billingAddress;
}

class Customer {
  Name name;
}

class Name {
  String firstName;
  String lastName;
}

class Address {
  String street;
  String city;
}

Destination model

// Assume getters and setters
class OrderDTO {
  String customerFirstName;
  String customerLastName;
  String billingStreet;
  String billingCity;
}

Mapping

ModelMapper modelMapper = new ModelMapper();
OrderDTO orderDTO = modelMapper.map(order, OrderDTO.class);

You can see in this example that the OrderDTO has a flattened projection of the customer and billing properties, which is very handy especially making usable DTOs from @ManyToOne DB relationships.

The Issues

My first issue was the method signature of ModelMapper.map which is (Object, Class), meaning, ModelMapper will try its hardest to map whatever you give it, even if it doesn’t make sense. This can have the consequence that you need to be alert to the fact that the compile will not let you know about invalid code.

My second, and more impactful issue was that when the objects get complex, the mapping can get weird.

Given the way that tokens are matched there were situations where, it is possible for sub objects with similar names to get cross-wired. I don’t have the original example, but something like the following, where lockedFields is a computed property that the UI uses to prevent editing of particular fields

class Person {
    PersonType type;
    String name;
}

class LockedFields {
    Boolean name;
    Boolean type;
}

class PersonDto {
    PersonType type;
    String name;
    LockedFields lockedFields
}

In this situation, the name and type fields on PersonDto may not get set, rather ModelMapper will attempt to map these values to lockedFields.name and lockedFields.type even though the root object is a closer match, and the types don’t match to the LockedFields version.

To prevent this you have to manually specify the following, for which I find the syntax to be quite unintuitive.

modelMapper.addMappings(new PropertyMap<Person, PersonDto>() {
            @Override
            protected void configure() {
                // We have to skip these because the standard mapping does some weird things
                skip().getLockedFields().setName(null);
                skip().getLockedFields().setType(null);
            }
        });

It is also hard to create generated properties that might rely on multiple input properties, and you end up using modelMapper.createTypeMap(...).setPostConverter(...) quite a lot.

But the real unexpected killer in all of this is the time it takes to build the ModelMappers at run time. This is, of course, a factor of the mapping strategy (standard), and the fact it has to try all the permutations of the tokens to get the fields to match. These mappers are created dynamically on first use, or explicitly using modelMapper.createTypeMap. In my case I had a few customisations, so had the createTypeMap in the setup phase of my service beans, which results in the mappers getting setup at startup, which is desirable.

On switching from ModelMapper to MapStruct, with the same functionality, my startup time dropped from 300s to 90s. The first hint that this might be the case was when I used JMC to run a profile on my app during startup, and most of the time was spent in the regex library, which is what ModelMapper uses for doing the token matching.

The Switch

MapStruct is an annotation processing library, which means that it does code generation at compile time. It does this processing based on the interfaces and abstract classes that are annotated with @Mapper. (note, you can reuse mappers from other classes, and it also supports IOC/DI containers such as Spring using the componentModel annotation argument)

While MapStruct will automatically map properties between the source and destination beans when their names are identical, there is more manual setup to do to get MapStruct to map nested properties.

@Mapper
public abstract class OrderMapper {
    @Mappings({
        @Mapping(target = "customerFirstName", source = "customer.firstName"),
        @Mapping(target = "customerLastName", source = "customer.lastName"),
        @Mapping(target = "billingStreet", source = "billingAddress.street"),
        @Mapping(target = "billingCity", source = "billingAddress.city"),
    })
    public abstract OrderDto toOrderDto(Order order);
}

However, this isn’t terribly verbose, and being explicit will save a lot of debug time, as will being able to inspect the generated code which is available in an *Impl class (e.g., OrderMapperImpl).

It is also trivial to provide custom implementations for any mapping using @Mapping(expression="java(functionName(order))").

Conclusion

While some of the issues I was hitting with ModelMapper could have been overcome without switching, others, like the type safety could not. Ultimately, I believe doing something at compile time is better than at run time, especially when you can then inspect the code to understand how things are working.

The reason I didn’t use MapStruct in the first place was that there used to be an issue using MapStruct with Lombok in Eclipse, but that has since been fixed.