1. Overview

Mapstruct is a code generator that simplifies the implementation of mappings between bean types.

In this tutorial, we’ll see how to use Mapstruct to create mappings between data classes in Kotlin.

2. Project Setup

Let’s create a Kotlin project using Spring Boot to see how we can use Mapstruct with Kotlin.

Our application will have a bean and a DTO class. We’ll use Mapstruct to map the properties of the bean to the DTO class and vice-versa.

2.1. Dependencies

First, we’ll add the Mapstruct dependency to our pom.xml:

<dependency>
    <groupId>org.mapstruct</groupId>
    <artifactId>mapstruct</artifactId>
    <version>1.5.3.Final</version>
</dependency>

Next, to enable Mapstruct annotation processing, we need to add the Mapstruct processor in the kotlin-maven-plugin:

<plugin>
    <artifactId>kotlin-maven-plugin</artifactId>
    <groupId>org.jetbrains.kotlin</groupId>
    <!-- other properties -->
    <dependencies>
       <!-- other dependencies --> 
        <dependency>
            <groupId>org.mapstruct</groupId>
            <artifactId>mapstruct-processor</artifactId>
            <version>${mapstruct.version}</version>
        </dependency>
    </dependencies>
    <executions>
        <execution>
            <id>kapt</id>
            <goals>
                <goal>kapt</goal>
            </goals>
            <configuration>
                <sourceDirs>
                    <sourceDir>src/main/kotlin</sourceDir>
                </sourceDirs>
                <annotationProcessorPaths>
                    <annotationProcessorPath>
                        <groupId>org.mapstruct</groupId>
                        <artifactId>mapstruct-processor</artifactId>
                        <version>${mapstruct.version}</version>
                    </annotationProcessorPath>
                </annotationProcessorPaths>
            </configuration>
        </execution>
    </executions>
</plugin>

2.2. Code Example

Let’s start with the User class:

data class User(
    val id: Long,
    var name: String,
    val createdAt: Date
)

Now, let’s create the UserDto class:

data class UserDto(
    val id: Long,
    var name: String,
    val createdAt: Date
)

3. Mapper Interface

Now, let’s create a mapper interface to map the properties of the bean to the DTO class:

@Mapper
interface UserMapper {
    fun toDto(user: User): UserDto
    fun toBean(userDto: UserDto): User
}

The @Mapper annotation is used to tell Mapstruct that this interface is a mapper interface and its implementation needs to be generated.

Here, the toDto() method is used to map the properties of the bean to the DTO class. Similarly, the toBean() method is used to map the properties of the DTO class to the bean.

3.1. Generated Code

As Kotlin code is compiled into Java class files, Mapstruct also generates Java class files during compilation. This code is a Java class that implements the mapper we defined earlier.

Let’s take a look at the generated code:

@Generated(
    value = "org.mapstruct.ap.MappingProcessor",
    date = "2023-06-01T18:42:22+0530",
    comments = "version: 1.5.3.Final, compiler: javac, environment: Java 19.0.1 (Oracle Corporation)"
)
public class UserMapperImpl implements UserMapper {

    @Override
    public UserDto toDto(User user) {
        if ( user == null ) {
            return null;
        }

        String name = null;
        long id = 0L;
        Date createdAt = null;

        name = user.getName();
        id = user.getId();
        createdAt = user.getCreatedAt();

        UserDto userDto = new UserDto( id, name, createdAt );

        return userDto;
    }

    @Override
    public User toBean(UserDto userDto) {
        if ( userDto == null ) {
            return null;
        }

        String name = null;
        long id = 0L;
        Date createdAt = null;

        name = userDto.getName();
        id = userDto.getId();
        createdAt = userDto.getCreatedAt();

        User user = new User( id, name, createdAt );

        return user;
    }
} 

As we can see, Mapstruct generates the implementation of the mapper interface. The generated code is very similar to the code that we’d have written manually to set the properties of the DTO class from the bean and vice-versa.

3.2. Mapping With Different Field Names

The above mapper declaration works only if the properties of the bean and the DTO class have the same name. If the properties have different names, we can use the @Mapping annotation to map the properties:

Let’s change the name of the createdAt property to createdOn in the DTO class:

data class UserDto(
    // ... other fields
    val createdOn: Date
)

Now, let’s update the mapper interface to use the @Mapping annotation:

@Mapper
interface UserMapper {
    @Mapping(source = "createdAt", target = "createdOn")
    fun toDto(user: User): UserDto
    
    @Mapping(source = "createdOn", target = "createdAt")
    fun toBean(userDto: UserDto): User
}

This would map the createdAt property of the bean to the createdOn property of the DTO class and vice-versa.

4. Advanced Mappings

Let’s look at some advanced scenarios that may be required in real-world applications and how to modify the mapper interface to handle them.

4.1. Nested Beans

If both the bean and the DTO class have nested beans, we can define more methods in the mapper to map the nested beans.

For example, let’s add a nested Address bean to the User bean:

data class User(
    // ... other fields
    val address: Address
)

Similarly, let’s add a nested AddressDto object in the UserDto class:

data class UserDto(
    // ... other fields
    val address: AddressDto
)

The only change that we need to make to the mapper interface is to add mapping methods for the nested beans:

@Mapper
interface UserMapper {
    // ... other methods
    
    fun toAddressDto(address: Address): AddressDto
    fun toAddress(addressDto: AddressDto): Address
}

4.2. Type Conversion

Sometimes, we may need to convert the type of a property. For example, let’s say that we want to convert between Date in the bean and String in the DTO.

Let’s change the type of the createdOn property in the UserDto class to String:

data class UserDto(
    // ...
    val createdOn: String
)

We can use the @Mapping annotation to convert the type of the property:

@Mapper
interface UserMapper {
    @Mapping(source = "createdAt", target = "createdOn", dateFormat = "yyyy-MM-dd HH:mm:ss")
    fun toDto(user: User): UserDto
    
    @Mapping(source = "createdOn", target = "createdAt", dateFormat = "yyyy-MM-dd HH:mm:ss")
    fun toBean(userDto: UserDto): User
}

The dateFormat attribute is used to specify the format of the date string. If the date format is not correct when converting from String to Date, an exception will be thrown.

If we look at the generated code, we can see that Mapstruct uses the SimpleDateFormat class to convert the date:

interface UserMapperImpl : UserMapper {
    
    override fun toDto(user: User): UserDto {
        val userDto = UserDto()
        userDto.id = user.id
        userDto.name = user.name
        userDto.createdOn = SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(user.createdAt)
        return userDto
    }
    
    override fun toBean(userDto: UserDto): User {
        val user = User()
        user.id = userDto.id
        user.name = userDto.name
        user.createdAt = SimpleDateFormat("yyyy-MM-dd HH:mm:ss").parse(userDto.createdOn)
        return user
    }
}

4.3. Default Values, Expressions, and Constants

We can also define rules for the value of a property.

There are three ways to define the value:

  • Constant value – If the target value will never change regardless of the source value, we can use the constant attribute and specify the constant value.
  • Default value – We can use the defaultValue attribute to specify the default value when the source value is null.
  • Default expression – If we want to call a method to set the default value, we can use the defaultExpression attribute.

To demonstrate these, let’s add a new property status to the User bean and the UserDto classes:

data class User(
    // ...
    val status: String? = null
)
data class UserDto(
    // ...
    val status: String? = null
)

Since all fields in Kotlin are non-null by default, we need to specify the default value as null to make the status field nullable.

If we want to give a constant value to the status property when it’s returned in the DTO, we can use the constant attribute:

@Mapper
interface UserMapper {
    // ...
    @Mapping(target = "status", constant = "ACTIVE")
    fun toDto(user: User): UserDto
}

When setting a value to a constant, we need to specify the target attribute to specify the target property.

Let’s say that we want to set the default value of the status property to ACTIVE in the bean if the DTO value is nullWe can do so using the defaultValue attribute:

@Mapper
interface UserMapper {
    // ...
    @Mapping(source = "status", target = "status", defaultValue = "ACTIVE")
    fun toBean(userDto: UserDto): User
}

The defaultExpression attribute can be used to call a method to set the default value when the source value is null. Let’s use it to set the createdAt property to the current date if the DTO value is null:

@Mapper
interface UserMapper {
    // ...
    @Mapping(source = "createdOn", target = "createdAt", dateFormat = "yyyy-MM-dd HH:mm:ss", 
      defaultExpression = "java(new java.util.Date())")
    fun toBean(userDto: UserDto): User
}

Again we’ll need to make createdOn nullable in the DTO class in order to be able to give it a null value:

data class UserDto(
    // ...
    val createdOn: String? = null
)

5. Testing the Mapper

Let’s write some tests to verify that the mapper is working as expected according to the mapping rules that we defined.

First, let’s create a test class for the mapper:

class UserMapperIntegrationTest {
    private var userMapper: UserMapper = Mappers.getMapper(UserMapper::class.java)
}

Here, we are using the Mappers.getMapper() method of Mapstruct to get an instance of the mapper interface. This injects the generated implementation of the mapper interface at runtime.

5.1. Mapping Bean to DTO

Let’s verify that the toDto() method is working as expected:

@Test
fun `should map entity to dto`() {
    val user = User(
        id = 1,
        name = "John Doe",
        createdAt = Date().apply {
            time = 1679682600000 // 2023-03-25 00:00:00
        },
        address = Address (
            streetAddress = "123 Main St",
            zipCode = "12345"
        ),
        status = "UNKNOWN"
    )
    val userDto = userMapper.toDto(user)
    assertEquals( user.id, userDto.id)
    assertEquals(user.name, userDto.name)
    assertEquals("2023-03-25 00:00:00", userDto.createdOn)
    assertEquals( user.address.zipCode, userDto.address.zipCode)
    assertEquals("ACTIVE", userDto.status)
}

Let’s look at the assertions in the test to understand how the mapping is expected to work:

  • The idname, and address properties are mapped directly from the User bean to the UserDto class, and the values should be the same.
  • The createdAt property should get converted to a string in the specified format, and the value should be set in the createdOn property in the DTO class.
  • The status property must have a constant value of ACTIVE in the DTO class even though the value in the entity was UNKNOWN.

5.2. Mapping DTO to Bean

Now let’s write a test to verify that the toBean() method is working as expected:

@Test
fun `should map dto to entity`() {
    val userDto = UserDto(
        id = 1,
        name = "John Doe",
        address = AddressDto (
            streetAddress = "123 Main St",
            zipCode = "12345"
        )
    )
    val user = userMapper.toBean(userDto)
    assertEquals(userDto.id, user.id)
    assertEquals(userDto.name, user.name)
    assertEquals(userDto.address.zipCode, user.address.zipCode)
    assertNotNull(user.createdAt)
    assertEquals("ACTIVE", user.status)
}

Let’s look at some of the checks in the test to understand how the mapping is expected to work:

  • Again, the idname, and address values should be the same.
  • The createdOn property in the DTO class is null, yet the createdAt property in the bean should not be null. This is because we’ve set the defaultExpression attribute to set the createdAt property to the current date if the DTO value is null.
  • The status property is also null in the DTO class. Since we’ve set the defaultValue attribute to set the status property to ACTIVE, the status property in the bean should have a value of ACTIVE.

6. Customized Mappings

In addition to the available annotations, we can also define custom implementations for our object mapping. Mapstruct provides two ways to define custom implementations – decorators and abstract classes.

6.1. Decorators

Decorators can be used to run additional logic along with the generated mapping methods. This is a good option if we want to use the generated mapping method first and then add additional logic.

First, let’s add the @DecoratedWith annotation to the mapper interface:

@Mapper
@DecoratedWith(UserMapperDecorator::class)
abstract class DecoratedMapper: UserMapper {
}

Here, we’re telling Mapstruct to use the UserMapperDecorator class to decorate the generated mapping methods. The class DecoratedMapper is an abstract class that implements the UserMapper interface. This is required because Mapstruct doesn’t support decorating interfaces in Kotlin.

Let’s say that we want to add a title prefix to the name property in the UserDto class. We can do so in the UserMapperDecorator class:

open class UserMapperDecorator: DecoratedMapper() {

    var userMapper: UserMapper = Mappers.getMapper(DecoratedMapper::class.java)

    override fun toDto(user: User): UserDto {
        val userDto = userMapper.toDto(user)
        userDto.name = "Mr. " + userDto.name
        return userDto
    }

    override fun toBean(userDto: UserDto): User {
        return userMapper.toBean(userDto)
    }

    override fun toAddressDto(address: Address): AddressDto {
        return userMapper.toAddressDto(address)
    }

    override fun toAddress(addressDto: AddressDto): Address {
        return userMapper.toAddress(addressDto)
    }
}

The UserMapperDecorator class implements the DecoratedMapper interface. We have provided an implementation for the toDto() method. First, we call the mapper interface’s generated toDto() method and then add the title prefix to the name property. Finally, we return the modified UserDto object.

For methods that we don’t want to customize, we can simply call the generated method of the mapper interface.

Let’s write a test to verify that the decorator is working as expected:

class DecoratedMapperUnitTest {
    private var userMapper: DecoratedMapper = Mappers.getMapper(DecoratedMapper::class.java)

    @Test
    fun `should map entity to dto`() {
        val user = User(
            id = 1,
            name = "John Doe",
            createdAt = Date().apply {
                time = 1679682600000 // 2023-03-25 00:00:00
            },
            address = Address (
                streetAddress = "123 Main St",
                zipCode = "12345"
            ),
        )
        val userDto = userMapper.toDto(user)
        assertEquals("Mr. John Doe", userDto.name)
    }
}

In our test class, first, we change the mapper we are using to the DecoratedMapper interface. Then, we write a test to verify that the name property in the UserDto class has the title prefix. When we run the test, we can see that the name has changed from John Doe to Mr. John Doe, as expected.

6.2. Abstract Classes as Mappers

If we want to define the entire mapping logic of a method ourselves, we can use abstract classes as mappers. This is a good option if one or more methods of the mapper need too much customization from the generated mapping methods.

Let’s look at an example to understand how this works:

@Mapper
abstract class AbstractMapper {

    fun toDto(user: User): UserDto {
        return UserDto(
            id = user.id,
            name = "Mr. ${user.name}",
            createdOn = SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(user.createdAt),
            address = AddressDto(
                streetAddress = user.address.streetAddress,
                zipCode = user.address.zipCode
            ),
            status = "ACTIVE"
        )
    }

    @Mapping(
        source = "createdOn",
        target = "createdAt",
        dateFormat = "yyyy-MM-dd HH:mm:ss",
        defaultExpression = "java(new java.util.Date())"
    )
    @Mapping(source = "status", target = "status", defaultValue = "ACTIVE")
    abstract fun toBean(userDto: UserDto): User

    abstract fun toAddressDto(address: Address): AddressDto
    abstract fun toAddress(addressDto: AddressDto): Address
}

Here, we have defined the toDto() method ourselves. We have marked all the other methods as abstract. MapsStruct will generate the implementation for the abstract methods. We can perform the same test as for the decorator, and the result will be the same.

7. Before and After Mapping

MapStruct provides two annotations that can perform operations before and after the mapping is performed – @BeforeMapping and @AfterMapping. We’ll look at how these annotations can be used.

7.1. @AfterMapping

The @AfterMapping annotation can perform operations on the target object after the mapping is performed. Let’s take the same example of adding a title prefix to the name property in the UserDto class.

@AfterMapping and @BeforeMapping don’t work with interfaces in Kotlin. This is because of the way Kotlin handles default methods in interfaces. So, we’ll have to use an abstract class as the mapper:

@Mapper
abstract class BeforeAndAfterMappingUserMapper: UserMapper {

    @AfterMapping
    fun afterMapping(@MappingTarget userDto: UserDto) {
        userDto.name = "Mr. " + userDto.name;
    }
}   

This method will be called after any mapping method that returns a UserDto object. In our case, after the toDto() method. We have used the @MappingTarget annotation to specify that the userDto parameter is the target object.

Let’s write a test to verify that the @AfterMapping annotation is working as expected:

class BeforeAndAfterMappingUserMapperUnitTest {
    private var userMapper: BeforeAndAfterMappingUserMapper = Mappers.getMapper(BeforeAndAfterMappingUserMapper::class.java)

    @Test
    fun `should map entity to dto`() {
        val user = User(
            id = 1,
            name = "John Doe",
            createdAt = Date().apply {
                time = 1679682600000 // 2023-03-25 00:00:00
            },
            address = Address (
                streetAddress = "123 Main St",
                zipCode = "12345"
            ),
        )
        val userDto = userMapper.toDto(user)
        assertEquals("Mr. John Doe", userDto.name)
    }
}

7.2. @BeforeMapping

Similarly, the @BeforeMapping annotation can perform operations on the source object before the mapping is performed.

Since we added the title prefix to the name property in the UserDto class, we can use the @BeforeMapping annotation to remove the prefix before the DTO is mapped to the User bean:

@Mapper
abstract class BeforeAndAfterMappingUserMapper: UserMapper {

    @BeforeMapping
    fun beforeMapping(userDto: UserDto) {
        userDto.name = userDto.name.replace("Mr. ", "")
    }
    
    @AfterMapping
    fun afterMapping(@MappingTarget userDto: UserDto) {
        userDto.name = "Mr. " + userDto.name;
    }

}

We have used the @BeforeMapping annotation to specify that the beforeMapping() method should be called before any mapping method that takes a UserDto object as a parameter. In our case, this is before the toBean() method.

Let’s write a test to verify that the @BeforeMapping annotation is working as expected:

class BeforeAndAfterMappingUserMapperUnitTest {
    private var userMapper: BeforeAndAfterMappingUserMapper = Mappers.getMapper(BeforeAndAfterMappingUserMapper::class.java)

    @Test
    fun `should map dto to entity`() {
        val userDto = UserDto(
            id = 1,
            name = "Mr. John Doe",
            createdOn = "2023-03-25 00:00:00",
            address = AddressDto(
                streetAddress = "123 Main St",
                zipCode = "12345"
            ),
            status = "ACTIVE"
        )
        val user = userMapper.toBean(userDto)
        assertEquals("John Doe", user.name)
    }
}

When we run the test, we’ll find that the name property in the User bean has the title prefix removed.

8. Conclusion

In this article, we learned how to use MapStruct to map between Kotlin’s data classes. We also looked at some advanced scenarios that may be required in real-world applications.

As always, the source code for the examples is available over on GitHub.

3 Comments
Oldest
Newest
Inline Feedbacks
View all comments
Comments are closed on this article!