Mapping object models with MapStruct - a case study
Introduction
I was once told that a good programmer is a lazy programmer. A good programmer avoids typing tedious code, where the code could basically write itself. Self-writing code – wouldn’t that be great? Unfortunately, we’re not quite there yet. We show you how close to self-writing code you can get. In this blog we will present a case study using MapStruct for mapping object models. In particular, we zoom in on how the MapStruct framework alleviates us from the dull task of writing code to copy one object model to another, field by field.
Note that, this blog is not meant to be a gentle introduction to mapping object models with MapStruct. There are many great articles out there that do just that. The goal of this blog is to show you: (1) our reasons for choosing MapStruct over similar frameworks, (2) why we like it so much, and (3) the things we struggled with before we actually started liking it. We hope this information helps you decide if MapStruct could also work in your project. Before we start, let’s look at why we would even bother writing mappers in the first place.
And, pssst, don’t tell our manager that we generate our code!
Why you should map object models
We have a RESTful service that has three kinds of object models of similar structure. Firstly, we have a json object model defining the API enabling consumers to communicate with our service. Secondly, we use an internal object model in the service’s business logic. Lastly, our service uses a library which comes with its own object model.
Now you might ask, why not use the json objects or library objects directly in our domain logic, to avoid all these unnecessary mappings? Of course, this works if your domain entities are exactly the same as your data transfer objects and will stay the same in the future. What if they don’t? Suppose, for example, you add some field that is only used in your business logic?
First, assume that we recycle the object model that comes with the library. Best case scenario, you can make changes to the library’s code and can actually add the new field to the existing model. Let’s think about the consequences of doing so. The field you just added might only have meaning in a very isolated spot. Yet it suddenly appears all over your code base and other users, for whom the field might not mean anything, now have to deal with it. Worst case scenario, you cannot make any changes to this library. Then it’s simple - you are stuck.
Next, assume that we use the json object model directly in our domain logic. Now even a simple change will leak to our REST interface and could break our clients’ code. So, if you care about your own code and that of your clients, you better decouple your domain entities from your data transfer objects! Convinced yet that you need mappers? If so, let us show you how to avoid writing them by yourself.
Why we chose MapStruct for mapping object models
We already spoiled that we picked MapStruct as our favourite tool, but it is not the only mapping framework one can choose from. Each framework has its own selling points. For us, performance, ease of use and community involvement were the main selection criteria.
Performance
Measuring performance of a framework is not an easy thing to do. Luckily, it has already been done for us. After reading this article (https://www.baeldung.com/java-performance-mapping-frameworks), that compares the performance of five popular Java mapping frameworks, we are left with only two candidates - MapStruct and JMapper outperform the others by far.
Ease of use
Of course, a tool’s ease of use is best verified by actually using it. (Don’t worry, we will get there soon.) Only by merely looking at JMapper’s documentation and supported features, we can already see that it doesn’t support the builder pattern. In the project referred to earlier, we use the immutables.org framework for automatically generating immutable domain objects. (Yes, we really try our best not to write code.) The immutables.org framework heavily relies on the builder pattern. So, when it comes to ease of use, a mapping framework that neglects the builder pattern is useless to us. Luckily, MapStruct not only claims to support builders in general, it also claims to integrate seamlessly with the immutables.org framework.
Community involvement
Even though the winner should be clear by now, let us have a quick look at our last selection criterion: community involvement. Looking at the number of active contributions of each github project, we can see that JMapper (https://github.com/jmapper-framework) did not have much involvement lately. In fact, no code has been committed in the last three years. MapStruct (https://github.com/mapstruct/), on the other hand, is receiving a steady flow of contributions made by several people for more than seven years now.
Overall, MapStruct looks great on paper. Let’s see if it can hold up when it's used in a real project.
Building a case study for mapping object models with MapStruct
To assess if MapStruct is usable for us, we incorporated it into one of our existing RESTful services. This service deals with domain objects that represent the selection a user made on a list page. A list page contains a list of products that a user can browse through. Optionally a user can select a category, such as sneakers, to only display casual, sporty shoes. If they already know what they’re looking for and don’t want to browse through thousands of sneakers, they can further limit their selection by refining on, for example, red sneakers or sneakers within the price range of 25-100 euros.
Builders
Just as much as we don't like writing mappers, we don't like writing hash, equals and copy methods for our domain objects. Instead of writing those methods ourselves, we let the immutables.org framework generate them for us. As a bonus, immutables.org generates object builders. This allows us to construct our objects in a fluent way. This makes the first case for MapStruct: how well does it generate mappers for builders?
List page contexts
The ListPageContextInternal object that is used internally in our REST service represents the user’s selection on a list page: an optional category id and a possibly empty list of refinements:
public interface ListPageContextInternal {
Optional<Long> getCategoryId();
List<RefinementInternal> getRefinements();
}
We need to convert this internal object to the corresponding object model (used within the library). This library object model is defined as:
public interface ListPageContext {
Optional<Category> getCategory();
List<Refinement> getRefinements();
}
Basically, a list page context wraps the category and refinements a user selected. When both category and refinements are absent, this means a user views all products. In what follows, we look at the category and refinements in more detail.
Categories
The implementation of the category is not important for what follows. However, for the sake of completeness: it encapsulates its identifier as well as its singular and plural name. Note that the library is provided with a full category object (line 2), whereas the service’s context merely has a category id (line 2). Our service fetches a full category object from an external service. This category is provided to the library list page context. This makes for another interesting case, as we map two source objects to one target.
Refinements
A refinement can come in three different flavours:
- identity refinements
- range refinements
- delivery period refinements.
An example of an identity refinement is a refinement on colour for which a user can pick one or more predefined values, e.g. red, green, and blue. An example of a range refinement is that of a refinement on price for which a user can select a range of values, e.g. 10-25 euros or 100-150 euros. An example of a delivery period refinement would be “deliver tomorrow”. This gives us a realistic case to verify how MapStruct deals with mapping object hierarchies.
MapStruct in a nutshell
Explaining how to define a mapper in MapStruct works so much better with a concrete example. So, consider the following mapper definition that maps our service’s refinement model to its library counterpart:
@Mapper
interface RefinementInternalToLibMapper {
@Mapping(target = "refinementId", source="id")
RangeRefinement toLib(RangeRefinementInternal refinement);
}
To define a mapper with MapStruct is to define an interface and annotate it with the @Mapper
annotation (line 1 and 2). We tell MapStruct we like to convert an internal RangeRefinement
to its library counterpart by defining a method (line 4). MapStruct will then happily generate the code for us. MapStruct is smart enough to figure out the target a field maps to as long as the fields share the same name. When they don’t, we need to tell MapStruct explicitly about the source and target field names (line 3).
The cool thing about MapStruct is that it does generate readable source code. The code of the mappers looks pretty much the same as you would have written it yourself. And, as we will argue later, this feature has proven to be very handy. Especially, while debugging issues for cases MapStruct did not generate the code you would expect.
Building a case for mapping object models with MapStruct
In short, has MapStruct proven beneficial for this case study? You might have guessed it - it did!
MapStruct managed to generate robust mappers for most scenarios we encountered in our use case. It handled objects with primitive and complex fields, lists and multiple source objects mapping onto one target. That already builds a strong case for MapStruct.
In fact, we also touched upon some interesting cases while conducting our experiment. In what follows, we briefly explain the bumps in the road we encountered and what we did to flatten them. We hope this gives you a head start, if you decide, just like us, to let MapStruct generate this tedious mapping code for you. In particular, we will look at to what extent MapStruct supports:
- Building mappers for objects that are constructed by the builder pattern
- Non-compulsory fields that are modelled by Java optional
- Class hierarchies
Building mappers for builders
As mentioned earlier, MapStruct claims good support for the immutables.org framework and its builders. To a certain extent it does. We only need to tell MapStruct that we use this framework by adding a few lines to the MapStruct build plugin in the maven pom. From there, everything works out of the box. But does it really?
As it turns out, MapStruct only supports certain types of builders. More specifically, it does not support staged builders, which we use in our project.
Let us explain the difference between a staged and non-staged builder. In a non-staged builder you can call all the builder methods in arbitrary order. That is, you can construct a refinement object in many ways, for example:
IdentityRefinement.builder().id("123").valueId("234").valueName("red").build();
IdentityRefinement.builder().valueName("red").valueId("234").id("1234").build();
IdentityRefinement.builder().valueName("red").valueId("234").build();
Did you spot that we forgot to set the mandatory refinement id in the last example (line 3)? If we are lucky, this will throw an exception when we invoke the build method. Wouldn’t it be great if we could detect that mistake at compile time already? Well, staged builders do just that by only allowing for a specific invocation order of the builder methods. For example, only the first one (line 1) would be a legit use of the builder. The other two examples (line 2 and line 3) will not compile.
MapStruct is not able to generate converters to map any object model to an object model built by staged builders. However, we should not expect support for this anytime soon:
“Honestly, I don't see supporting something like this from MapStruct. Currently this feels like something which would be extremely complex to implement and I personally don't see big benefit for this.” (filiphr, https://github.com/mapstruct/mapstruct/issues/1969)
Support for optional is not optional
In our object model, we wrap non-mandatory attributes in an Optional class. For us, it is important that MapStruct can handle this. Let’s explore whether it does.
Consider the case where we map a list page context to its library counterpart. On the library’s list page context, the category is defined as an optional field:
Optional<Category>
The category is not part of the list page context, so we provide it as a second argument to the mapper method. We expect the following mapper definition to do the trick:
@Mapper
public interface ListPageContextToLibMapper {
@Mapping
ListPageContext toLib(ListPageContextInternal context, Category category);
}
When compiling the code MapStruct informs us that our expectations are too high. MapStruct cannot generate a mapper based on this code, because it
can't map parameter "Category category" to "java.util.Optional<? extends Category> category".
Meaning that MapStruct is indeed smart enough to figure out that the category parameter of our mapper method corresponds to the category field of the library list page context object. It just does not know how to wrap the category in an optional. Indeed, this is a known issue, see for example: https://github.com/mapstruct/mapstruct/issues/674. We could easily fix this by using a simple work-around as suggested in the aforementioned ticket. The work-around boils down to manually implementing a method that wraps the possibly empty category in an optional:
@Mapper
public interface ListPageContextInternalToLibMapper {
@Mapping
ListPageContext toLib(ListPageContextInternal context, Category category);
default Optional<Category> wrapOptional(Category category) {
return Optional.ofNullable(category);
}
}
A future blog post will explain the problem in more detail and present a more reusable fix. For now, we conclude that MapStruct is able to manage Java optionals with a little effort.
Mapping multiple (optional) sources onto one target
The conclusion we drew in the previous section is only a preliminary one, because this was not the only issue we ran into whilst dealing with non-compulsory fields. We just told you that previous example works and we didn’t lie to you; we just haven’t told you the full story yet. In fact, the previous mapper only works in case the category is not null. Confused? So were we. However, having a look at the generated code might clear things up:
public ListPageContext toLib(ListPageContextInternal context, Category category) {
if ( context == null && category == null ) {
return null;
}
Builder listPageContext = ListPageContext.builder();
// Mapping code for listPageContext fields
if ( category != null ) {
listPageContext.category( wrapOptional( category ) );
}
return listPageContext.build();
}
See that null check there? As long as the category is present, everything is fine. If it is null, however, our method for wrapping the optional will never be invoked. Then the category on the list page context will be null instead of an empty optional.
Initially, we were not able to overcome this issue. Neither could we find any bug report explaining this behaviour. Only after quite some trial and error we discovered multiple workarounds. And, finally, after posting a message on the MapStruct mailing list we were pointed out to this bug report that even includes yet another workaround we were not able to find ourselves: https://github.com/mapstruct/mapstruct/issues/2023
By the way, did we just make you curious about those workarounds? We will elaborate on them in a future blog. We promise.
Mapping hierarchical object models
The last challenge we dealt with is MapStruct’s inability to manage class hierarchies. Recall that a user can narrow down the selection of products on a page by selecting one or more refinements. Therefore, we previously defined refinements as a class hierarchy.
Our aim is to have one convert method that takes a refinement of any type and yields a converted refinement of similar type. In other words, a range refinement maps onto a library range refinement and so forth. More concretely, we are looking for this mapping method:
Refinement toLib(RefinementInternal refinement)
I’ll cut to the chase: MapStruct has no off-the-shelf support for a base class (or interface) with children (cf. https://github.com/mapstruct/mapstruct/issues/366). That is, simply telling MapStruct that we want it to implement a converter for this method signature will not work. The good news is that the aforementioned ticket offers an easy work-around. The solution proposed is to have MapStruct generate the conversion method for each concrete refinement. We can then implement the generic conversion method for the refinement base class ourselves using instance-of checks to call the appropriate converter method for each subclass:
@Mapper
interface RefinementInternalToLibMapper {
default Refinement toLib(RefinementInternal refinement) {
if (refinement instanceof IdentityRefinementInternal) {
return map((IdentityRefinementInternal) refinement);
} else if (refinement instanceof RangeRefinementInternal) {
return map((RangeRefinementInternal) refinement);
} else if (refinement instanceof DeliveryPeriodRefinementInternal) {
return map((DeliveryPeriodRefinementInternal) refinement);
} else {
return null;
}
}
IdentityRefinement map(IdentityRefinementInternal refinement);
RangeRefinement map(RangeRefinementInternal refinement);
DeliveryPeriodRefinement map(DeliveryPeriodRefinementInternal refinement);
}
Not a fan of this kind of instance-of checks? Neither are we. In a future blog post we will offer you an alternative.
Conclusion
To wrap up: MapStruct has limited support for optional fields and class hierarchies, and does not support staged builders at all.
For the first two limitations - optional fields and class hierarchies - MapStruct offers a rich set of constructs that facilitated us to find suitable workarounds.
However, the lack of support for staged builders is a more serious issue. We typically use staged builders while writing converters. There they help us prevent mistakes. Seeing as we are letting MapStruct write that error-prone code for us now, we were willing to give up staged builders. But in case you are attached to your staged builders, it is good to be aware of this limitation before adopting MapStruct.
Tips and tricks for mapping object models with MapStruct
If we already convinced you to try out MapStruct yourself, here are some tips and tricks that could save you some time:
- Consider using MapStruct only if the source and target fields are mostly structured the same and fields share the same name. If they are not, you might be better off writing those mappers yourself.
- Don’t use MapStruct if your target is based on staged builders (and cannot change that). It will simply not work.
- You can actually turn on warnings (in your pom or gradle file). By enabling warnings, MapStruct informs you about fields it failed to map. You don’t want to miss essential fields while converting, do you? So, enable warnings.
- Some fields of your target model are intentionally left blank while mapping. You can use the ignore option the @Mapping annotation to suppress warnings about specific fields in your build. This will prevent you from cluttering the logs.
- Look at the code that is generated to verify that it indeed does as expected. Because, as you should know by now, sometimes it simply doesn’t.
- Do not only rely on looking at the generated code to assure yourself that your mapper works. Write automated tests to prove it! (Note that you are not testing the MapStruct framework, but you’re testing whether you used the framework correctly.)
- Can’t map it? Google it. You are not the only one using MapStruct, so if MapStruct does not do what you want it to do, or don’t know how to make MapStruct do it, chances are that someone already posted the answer to your question somewhere on the Internet. Still can’t find it? Use the MapStruct mailing list to reach out for help. You probably get an answer within a day.
Conclusion and evaluation
In this blog we alleviated ourselves from the tedious task of writing converters, mapping object models. We argued for the necessity of having separate object models within your code. This stipulates the need for having to write mappers in the first place. Furthermore, we explained why we chose MapStruct over other frameworks that perform the same task. To assess MapStruct's usability we performed a case study analysis using MapStruct in a real project. We discovered that MapStruct does not work if your target object model is built with staged builders. We revealed some problems dealing with Java optionals, especially when mapping multiple sources to one target. Finally, we discovered that MapStruct does not support object hierarchies out of the box. However, due to the rich set of constructs MapStruct supports we could overcome all these issues.
What we haven’t told you yet is whether or not MapStruct actually made our lives easier. After all, wasn’t that the entire point of this case study? Following our first experiment, mapping our service’s domain objects models to a library object model, the answer was a resounding yes! We also decided to use MapStruct to generate the mappers that convert our json object models to our internal domain models. For most cases where the models are fairly similar, defining the mapper was a matter of seconds!
So please go and tell our manager that we generate our tedious code for mapping object models automatically. That leaves us with more time to focus on the fun stuff!