Don’t get lost, take the map! – DTO survival code

There are a few topics that can turn programming community red hot. Plural or singular database table names? Should we use DTOs or not? And so on, and so forth. But that’s not the time to discuss these matters. Today we won’t deal with databases, but let’s face DTOs and map some of them around.

Some multilayer applications, or even some simple ones with a legacy code, need to take care of mapping some objects to another, usually domain objects to DTOs or different versions of DTOs. Say one is for administration, other for portal, and yet another for API. Or you need to map early version to the current one. We’ve all been there it’s not hard but rather bothersome and dull to write and maintain. Let’s look for some relief. It’s right around the corner I present you MapStruct.

MapStruct is a tool that can generate mapping classes between two types (aimed for DTOs). For a lot of cases all we need to do is create simple interface with @Mapper annotation. For the rest in need of some additional processing (different field names, combining different classes, flattening structure) we only have to explain differences within @Mappings annotation. MapStruct will run during e.g. Maven compilation phase (or wherever else you’d plug it in), look for those annotations and create or update appropriate mappings.

By now I probably lost attention of all the cool guys who don’t use DTOs. That’s fine, but they’ll be back some day. There are also some developers, who won’t let anyone or anything touch their mapping code, as they are the only ones to do that right. I bet they won’t waste time reading this and that’s good they need all the time possible to do manual mapping.

We don’t have all day for this either, so let’s get down to business, shall we? The use case will be simple I want to show you some basic capabilities of MapStruct.

We have the user and post objects for a blog system. It’s a new version, but there are some clients still using legacy API. That’s why we have the following two sets of DTOs:

package org.wkh.mappers.legacy;

import java.util.List;

public class User {
    private String login;
    private String email;
    private List posts;
    private String clientType;

    /* getters and setters */
}
package org.wkh.mappers.legacy;

import java.util.Date;

public class Post {
    private String title;
    private String content;
    private Date createdAt;
    private Integer views;
   
    /* getters and setters */
}
package org.wkh.mappers.dto;

import java.util.Date;
import java.util.List;

public class User {
    private String username;
    private String email;
    private List posts;
    private Date lastPostAt;
    private boolean isLegacy;

    /* getters and setters */
}
package org.wkh.mappers.dto;

import java.util.Date;

public class Post {
    private String title;
    private String description;
    private String content;
    private Date createdAt;
    private Integer views;
   
    /* getters and setters */
}

The first thing to create is a simple mapper. In order to do that, we only need to prepare interface declaring proper mapping methods. This interface will be implemented by generated code.

@Mapper
public interface BlogMapper {
    org.wkh.mappers.dto.User map(org.wkh.mappers.legacy.User value);

    java.util.List map(java.util.List value);

    org.wkh.mappers.dto.Post map(org.wkh.mappers.legacy.Post value);
}

Please note that since we’re dealing with collection mapping, we need to declare mapping for both collection and collection’s elements.

Here’s a sample test for our code:

package org.wkh.mappers;

import org.junit.Before;
import org.junit.Test;
import org.mapstruct.factory.Mappers;
import org.wkh.mappers.adapter.UserMapper;
import org.wkh.mappers.legacy.Post;
import org.wkh.mappers.legacy.User;

import java.util.ArrayList;
import java.util.Date;
import java.util.List;

import static org.hamcrest.CoreMatchers.is;
import static org.junit.Assert.assertThat;

public class AppTest
{
    public static final String EMAIL = "user@example.com";
    public static final String LOGIN = "user";
    public static final String CLIENT_TYPE = "iOS";
    public static final String POST_TITLE = "Lorem post";
    public static final String POST_CONTENT = "Lorem ipsum dolor sit amet, consectetur adipiscing elit.";
    public static final Date POST_DATE = new Date();

    private org.wkh.mappers.dto.User mappedUser;

    @Before
    public void prepare(){
        User user = new User();
        user.setLogin(LOGIN);
        user.setEmail(EMAIL);
        user.setClientType(CLIENT_TYPE);

        Post post = new Post();
        post.setTitle(POST_TITLE);
        post.setContent(POST_CONTENT);
        post.setCreatedAt(POST_DATE);

        List posts = new ArrayList();
        posts.add(post);

        user.setPosts(posts);

        UserMapper mapper = Mappers.getMapper(UserMapper.class);

        mappedUser = mapper.map(user);
    }

    @Test
    public void mappedUserShouldHaveCorrectEmail() {
        assertThat(mappedUser.getEmail(), is(EMAIL));
    }
}

Look what will happen when we run the test:

BlogNewsletterPropo_3 

Ok, we have one general mapper, which is fine for a simple project. But once we get more objects to map, we’d love to be able to split them. That’s of course possible. Just keep in mind that some mappers need to be aware of other mappers defining required methods. Here is an example of such a behaviour:

package org.wkh.mappers.adapter;

import org.mapstruct.Mapper;

@Mapper
public interface PostMapper {
    java.util.List map(java.util.List value);

    org.wkh.mappers.dto.Post map(org.wkh.mappers.legacy.Post value);
}
package org.wkh.mappers.adapter;

import org.mapstruct.Mapper;

@Mapper(uses = PostMapper.class)
public interface UserMapper {
    org.wkh.mappers.dto.User map(org.wkh.mappers.legacy.User value);
}

A user mapper needs to know where to look for methods mapping posts.

Right, now we are ready to do some serious stuff. As you could see, not many newUser fields were filled. If we inspect the compilation log, we can easily see the reason.

Unmapped target properties: "username, lastPostAt, isLegacy". org.wkh.mappers.dto.User map(org.wkh.mappers.legacy.User value);

MapStruct simply doesn’t know how to map properties that differ in names. We should specify that:

package org.wkh.mappers.adapter;

import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
import org.mapstruct.Mappings;

@Mapper(uses = PostMapper.class)
public interface UserMapper {
    @Mappings({
            @Mapping(source = "login", target = "username"),
            @Mapping(target = "lastPostAt", ignore = true),
            @Mapping(target = "isLegacy", constant = "true")
    })
    org.wkh.mappers.dto.User map(org.wkh.mappers.legacy.User value);
}

We used 3 simple tricks at once (if you want me to explain them, there you go: translated field name for username, ignored lastPostAt and set isLegacy to true, since in our case it is always true when we are mapping objects).

The compilation warning is gone. Now let’s extend our test:

package org.wkh.mappers;

import org.junit.Before;
import org.junit.Test;
import org.mapstruct.factory.Mappers;
import org.wkh.mappers.adapter.UserMapper;
import org.wkh.mappers.legacy.Post;
import org.wkh.mappers.legacy.User;

import java.util.ArrayList;
import java.util.Date;
import java.util.List;

import static org.hamcrest.CoreMatchers.is;
import static org.junit.Assert.assertThat;

public class AppTest
{
    public static final String EMAIL = "user@example.com";
    public static final String LOGIN = "user";
    public static final String CLIENT_TYPE = "iOS";
    public static final String POST_TITLE = "Lorem post";
    public static final String POST_CONTENT = "Lorem ipsum dolor sit amet, consectetur adipiscing elit.";
    public static final Date POST_DATE = new Date();

    private org.wkh.mappers.dto.User mappedUser;

    @Before
    public void prepare(){
        User user = new User();
        user.setLogin(LOGIN);
        user.setEmail(EMAIL);
        user.setClientType(CLIENT_TYPE);

        Post post = new Post();
        post.setTitle(POST_TITLE);
        post.setContent(POST_CONTENT);
        post.setCreatedAt(POST_DATE);

        List posts = new ArrayList();
        posts.add(post);

        user.setPosts(posts);

        UserMapper mapper = Mappers.getMapper(UserMapper.class);

        mappedUser = mapper.map(user);
    }

    @Test
    public void mappedUserShouldHaveCorrectEmail() {
        assertThat(mappedUser.getEmail(), is(EMAIL));
    }

    @Test
    public void mappedUserShouldHaveCorrectUsername() {
        assertThat(mappedUser.getUsername(), is (LOGIN));
    }

    @Test
    public void mappedUserShouldHaveOnePost() {
        assertThat(mappedUser.getPosts().size(), is(1));
    }

    @Test
    public void mappedUserShouldBeLegacy() {
        assertThat(mappedUser.isLegacy(), is(true));
    }

    @Test
    public void postShouldHaveCorrectTitle() {
        assertThat(mappedUser.getPosts().get(0).getTitle(), is(POST_TITLE));
    }

    @Test
    public void postShouldHaveCorrectContent() {
        assertThat(mappedUser.getPosts().get(0).getContent(), is(POST_CONTENT));
    }

    @Test
    public void postShouldHaveCorrectDate() {
        assertThat(mappedUser.getPosts().get(0).getCreatedAt(), is(POST_DATE));
    }
}

And run it:

BlogNewsletterPropo_5

 

Another useful feature is a possibility to add another parameter to mapping. We’ll use it to fill lastPostAt for user. We won’t ignore that field anymore but rather map it to another parameter like this:

package org.wkh.mappers.adapter;

import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
import org.mapstruct.Mappings;

import java.util.Date;

@Mapper(uses = PostMapper.class)
public interface UserMapper {
    @Mappings({
            @Mapping(source = "value.login", target = "username"),
            @Mapping(source = "lastPostAt", target = "lastPostAt"),
            @Mapping(target = "isLegacy", constant = "true")
    })
    org.wkh.mappers.dto.User map(org.wkh.mappers.legacy.User value, Date lastPostAt);
}

Since now we have different parameters, we also need to specify a source for the login field, which is ‘value’ parameter.

Again, we need to update the test. First, we have to change the mapping call:

mappedUser = mapper.map(user, post.getCreatedAt());

Now check the result:

Test
public void userLastPostAtShouldBeTheSameAsPostDate() {
    assertThat(mappedUser.getLastPostAt(), is(POST_DATE));
}

Does it work? It seems so:

BlogNewsletterPropo_6

 

One more thing we can specify in @Mapping is expression returning desired value of a target field. We can simply put a Java code there.

Let’s assume we want to fill the post’s description with the beginning of post content. Our example post is quite short, so the description will cut only first 5 letters. The implementation is naive, but that’s not the point here.

package org.wkh.mappers.adapter;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
@Mapper
public interface PostMapper {
    java.util.List map(java.util.List value);
    @Mapping(target = "description", expression = "java(value.getContent().substring(0, 5).trim();))")
    org.wkh.mappers.dto.Post map(org.wkh.mappers.legacy.Post value);
}

 Even regardless of cutting implementation, it is nasty, isn’t it? In that case I’d rather go for creating some util and calling it in expression:

package org.wkh.mappers.util;
public class PostUtil {
    public static String extractDescription(String postContent) {
        return postContent.substring(0, 5).trim();
    }
}

 

package org.wkh.mappers.adapter;

import org.mapstruct.Mapper;
import org.mapstruct.Mapping;

@Mapper
public interface PostMapper {
    java.util.List map(java.util.List value);

    @Mapping(target = "description", expression = "java(org.wkh.mappers.util.PostUtil.extractDescription(value.getContent()))")
    org.wkh.mappers.dto.Post map(org.wkh.mappers.legacy.Post value);
}

I’d say it’s slightly better. Now at least util implementation is testable. Unfortunately we need to use fully qualified names here.

But luckily that’s not the only way to do it. We can use abstract class to define mapping and provide full implementation of the method ourselves as follows:

package org.wkh.mappers.adapter;

import org.mapstruct.Mapper;
import org.wkh.mappers.util.PostUtil;

@Mapper
public abstract class PostMapper {
    public abstract java.util.List map(java.util.List value);

    public org.wkh.mappers.dto.Post map(org.wkh.mappers.legacy.Post value) {
        if ( value == null ) {
            return null;
        }

        org.wkh.mappers.dto.Post post = new org.wkh.mappers.dto.Post();

        post.setTitle(value.getTitle());
        post.setContent(value.getContent());
        post.setCreatedAt(value.getCreatedAt());
        post.setViews(value.getViews());

        post.setDescription(PostUtil.extractDescription(value.getContent()));

        return post;
    }
}

In case you wonder − yes, that’s the method generated by MapStruct, only copied to abstract class and cleaned up a bit − util class was imported, and post variable was renamed from “post_”. This is a trivial example, but you can do all the magic you want this way. Let’s add one more test case and check if it works:

@Test
public void postShouldHaveDescription() {
    assertThat(mappedUser.getPosts().get(0).getDescription(), is(POST_CONTENT.substring(0, 5)));
}

BlogNewsletterPropo_9

 

Yes, it did.

So there we are. We saw some basic features of MapStruct. There is a lot more, which you can check yourself on http://mapstruct.org. And if you miss some functionality, you can join the team at https://github.com/mapstruct/mapstruct

For now there is only one small problem: MapStruct is not officially released yet. But it’s getting there. The RC1 has been released recently, so MapStruct 1.0 should be ready any time soon. However we found it mature and stable enough to already introduce it in one of our projects, and it is doing quite well with much more than two users and posts. We find it both helpful and safe, since all the magic happens during the compilation phase.

I keep my fingers crossed for MapStruct development. Do you?

0

Tags:

1 comment

  1. Hi Wojciech,
    Thank you for a great post!
    I have a similar case when one mapper uses another:

    @Mapper(uses = PostMapper.class)
    public interface UserMapper {

    And when unit testing UserMapper the other mapper (PostMapper) is not injected into it and is null that leads to test fail.
    Could you please share the source code of your project, maybe I missed something.
    Thank you!

    0

Comments are closed.