All Articles

Mapstruct in Kotlin projects

Mapstruct
Use mapstruct to avoid writing boring mappers

In previous articles, for example here, we’ve worked with mapping DTOs to domain objects and back. So far, we’ve mostly used extension methods for this purpose.

Now, extension methods are great for this - however, one could argue that they are still too much work. Oftentimes, the mapping is really only copying values into an object that has fields with the exact same names. Enter mapstruct, a library that helps remove all that boilerplate code writing.

Setup in Kotlin

Setting up mapstruct for Java is really quite easy - for Kotlin, it’s not quite the case. However, I will show all the additions that are required for mapstruct to work in a Kotlin project.

I will not go into too much detail as to why all these steps are required, as this is really a “just get on with it” topic. :)

In the pom.xml, we need the dependency

<properties>
    ...
        <mapstruct.version>1.5.3.Final</mapstruct.version>
    </properties>
    <dependencies>
    ...
    <dependency>
        <groupId>org.mapstruct</groupId>
        <artifactId>mapstruct</artifactId>
        <version>${mapstruct.version}</version>
    </dependency>
</dependencies>

as well as the plugin for the generation of the mapper implementation.

<plugins>
...
    <plugin>
        <groupId>org.jetbrains.kotlin</groupId>
        <artifactId>kotlin-maven-plugin</artifactId>
        <version>${kotlin.version}</version>
        <executions>
            <execution>
                <id>compile</id>
                <phase>compile</phase>
                <goals>
                    <goal>compile</goal>
                </goals>
            </execution>
            <execution>
                <id>kapt</id>
                <goals>
                    <goal>kapt</goal>
                </goals>
                <configuration>
                    <sourceDirs>
                        <sourceDir>src/main/kotlin</sourceDir>
                        <sourceDir>src/main/java</sourceDir>
                    </sourceDirs>
                    <annotationProcessorPaths>
                        <annotationProcessorPath>
                            <groupId>org.mapstruct</groupId>
                            <artifactId>mapstruct-processor</artifactId>
                            <version>${mapstruct.version}</version>
                        </annotationProcessorPath>
                    </annotationProcessorPaths>
                </configuration>
            </execution>
            <execution>
                <id>test-compile</id>
                <phase>test-compile</phase>
                <goals>
                    <goal>test-compile</goal>
                </goals>
            </execution>
        </executions>
        <configuration>
            <args>
                <arg>-Xjsr305=strict</arg>
            </args>
            <compilerPlugins>
                <plugin>spring</plugin>
                <plugin>jpa</plugin>
            </compilerPlugins>
            <jvmTarget>11</jvmTarget>
        </configuration>
        <dependencies>
            <dependency>
                <groupId>org.jetbrains.kotlin</groupId>
                <artifactId>kotlin-maven-allopen</artifactId>
                <version>${kotlin.version}</version>
            </dependency>
            <dependency>
                <groupId>org.jetbrains.kotlin</groupId>
                <artifactId>kotlin-maven-noarg</artifactId>
                <version>${kotlin.version}</version>
            </dependency>
            <dependency>
                <groupId>org.mapstruct</groupId>
                <artifactId>mapstruct-processor</artifactId>
                <version>${mapstruct.version}</version>
            </dependency>
        </dependencies>
    </plugin>
</plugins>

This is about what you’d need in Java. So, if you run your mvn clean compile -U now, you should see the mapper, right? If you want to try it out already, you can skip ahead and view the mapper.

So, does it work? Well, yes, and no. If you look into the target folder, you will see the implementations. However, the compilation fails. This can be fixed by adding the following plugin execution in the pom.xml:

<plugins>
    <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-compiler-plugin</artifactId>
        <version>3.5.1</version>
        <executions>
            <!-- Replacing default-compile as it is treated specially by maven -->
            <execution>
                <id>default-compile</id>
                <phase>none</phase>
            </execution>
            <!-- Replacing default-testCompile as it is treated specially by maven -->
            <execution>
                <id>default-testCompile</id>
                <phase>none</phase>
            </execution>
            <execution>
                <id>java-compile</id>
                <phase>compile</phase>
                <goals>
                    <goal>compile</goal>
                </goals>
            </execution>
            <execution>
                <id>java-test-compile</id>
                <phase>test-compile</phase>
                <goals>
                    <goal>testCompile</goal>
                </goals>
            </execution>
        </executions>
    </plugin>
</plugins>

Now that this has been added, it should be sufficient. Let’s try it out!

First usage

As stated earlier, we’d like to replace some extension methods. Here are the two classes that we’ll use, and the extension method that will be deleted afterwards:

//DTO
data class CoinGeckoCryptoAssetDto(
    val id: String,
    val symbol: String,
    val name: String,
    @JsonProperty("current_price")
    val price: Double,
    @JsonProperty("market_cap_rank")
    val marketCapRank: Int
)

//Domain entity (column names etc have been removed for simplicity
data class CryptoAsset(
    val id: String,
    val symbol: String,
    val name: String,
    val marketCapRank: Int,
    val latestPrice: Double,
    @Transient @JsonIgnore var price: Double,
    val priceHistory: MutableList<CryptoAssetPrice> = mutableListOf(),
    val positions: MutableList<CryptoPosition> = arrayListOf(),
)

// Extension method previously used
fun CoinGeckoCryptoAssetDto.toDomain() = CryptoAsset(
    id = this.id,
    name = this.name,
    symbol = this.symbol.uppercase(),
    marketCapRank = this.marketCapRank,
    price = this.price,
    latestPrice = this.price
)

That’s the basic setup. Now we’ll add the simplest of Mappers:

@Mapper
interface CoinGeckoDtoMapper {
    fun toDomain(assetDto: CoinGeckoCryptoAssetDto): CryptoAsset
}

Now, if we run mvn clean compile -U (clean and -U are technically not required, but definitely a good habit), we get a generation of the following class:

public class CoinGeckoDtoMapperImpl implements CoinGeckoDtoMapper {

    @Override
    public CryptoAsset toDomain(CoinGeckoCryptoAssetDto assetDto) {
        if ( assetDto == null ) {
            return null;
        }

        double price = 0.0d;
        String id = null;
        String symbol = null;
        String name = null;
        int marketCapRank = 0;

        price = assetDto.getPrice();
        id = assetDto.getId();
        symbol = assetDto.getSymbol();
        name = assetDto.getName();
        marketCapRank = assetDto.getMarketCapRank();

        double latestPrice = 0.0d;
        List<CryptoAssetPrice> priceHistory = null;
        List<CryptoPosition> positions = null;

        CryptoAsset cryptoAsset = new CryptoAsset( id, symbol, name, marketCapRank, latestPrice, price, priceHistory, positions );

        return cryptoAsset;
    }
}

Here are some notable items:

  • The class is actually in Java!
  • All fields to be mapped are first instantiated with default values before being set with correct values
  • In a separate block, the fields with different names (e.g. latestPrice) are set to default values
  • Default values for Lists are null

Still, considering that it’s such little effort to generate that, I’d say it’s a rather good deal!

Basic mapping annotations

Okay, now let’s tackle some items that can be improved. Generally, specific mappings are defined with @Mapping.

In Java, those can be repeated on the mapping method. In Kotlin, however, you cannot repeat the same annotation anymore. Fortunately, you can simply wrap all your @Mappings like so:

@Mappings(
        Mapping(target = "abc", source = "abcd"),
        Mapping(target = "xyz", source = "xy"),
        ...
    )

Let’s check out some frequently used options in mapstruct then! The full list of mapstruct operations can be found here, and it’s enormous. I will only cover some of the more frequently used ones here.

Differently named fields

@Mapping(target = "latestPrice", source = "price")
// generates
double latestPrice = 0.0d;
latestPrice = assetDto.getPrice();

Ignored properties

@Mapping(target = "priceHistory", ignore = true)
// generates
List<CryptoAssetPrice> priceHistory = null;

You may argue that this is the same as not defining anything, but depending on the Configuration that you make (more to this below), this is really required.

Default values

@Mapping(target = "symbol", source = "symbol", defaultValue = "BTX")
// generates
String symbol = null;
if ( assetDto.getSymbol() != null ) {
    symbol = assetDto.getSymbol();
}
else {
    symbol = "BTX";
}

Constant

@Mapping(target = "latestPrice", constant = "2.0d")
// generates
double latestPrice = 2.0d;

Expressions

Expressions are pretty neat. You can write direct code, for example setting a date. If other classes are needed however, they have to be imported in the Mapper definition.

@Mapping(target = "date", expression = "java(LocalDate.now())")
// generates
LocalDate date = LocalDate.now();

Hierarchy breakdown mapping

If there are complex objects in the DTO for example, you can also use json syntax to break down hierarchy:

@Mapping(target = "latestPrice", source = "nested.nestedValue")
// generates
double latestPrice = 0.0d;
latestPrice = assetDtoNestedNestedValue( assetDto );

private double assetDtoNestedNestedValue(CoinGeckoCryptoAssetDto coinGeckoCryptoAssetDto) {
    if ( coinGeckoCryptoAssetDto == null ) {
        return 0.0d;
    }
    NestedClass nested = coinGeckoCryptoAssetDto.getNested();
    if ( nested == null ) {
        return 0.0d;
    }
    double nestedValue = nested.getNestedValue();
    return nestedValue;
}

Quite verbose, but you don’t have to write any of it, and the null safety is pretty great!

Empty lists instead of nulls

@Mapping(target = "priceHistory", expression = "java(List.of())")
// generates
List<CryptoAssetPrice> priceHistory = List.of();

There are better ways to have some of these defaults set however. More on this below.

Configurations

Okay, so, after having played around a bit with the Mapping, you may want to actually use it. So, after autowiring your Mapper into some Component, you realize that the application doesn’t start anymore.

The reason is that the MapperImpl is simply a class. If you want to create a Bean, you can do so, with some additional settings.

Now, we can put those settings on the Mapper interface itself, or we can put them inside a central place. That’s what I prefer:

@MapperConfig(
    componentModel = "spring", // this actually adds the @Component annotation on the generated Impl
    unmappedTargetPolicy = ReportingPolicy.ERROR,  // this setting is the reason I define the ignored properties
    mappingInheritanceStrategy = MappingInheritanceStrategy.AUTO_INHERIT_FROM_CONFIG
)
interface CentralConfig {
}

@Mapper(config = CentralConfig::class)
...

Now the generated Impl will look as follows, and can be safely injected wherever it’s needed!

@Generated(...)
@Component
public class CoinGeckoDtoMapperImpl implements CoinGeckoDtoMapper {

    @Override
    public CryptoAsset toDomain(CoinGeckoCryptoAssetDto assetDto) {
    ...
    }

Nested mappers

So far, all mappings were done on a fairly flat level. This is not usually the case, and sometimes you have complex objects as fields. Fortunately, mapstruct has got you covered, and without much effort, will make use of other mappers you declare.

First, some additional nested classes:

data class CoinGeckoCryptoAssetDto(
    ...
    val sampleDto: SampleDto
)

data class SampleDto(
    val id: UUID,
    val name: String
)

data class CryptoAsset(
    ...
    @Transient val sample: SampleDomainObj,
    ...
) 
data class SampleDomainObj(val id: UUID,
                           val name: String)

Now, the mapper for SampleDto to SampleDomainObj:

@Mapper(config = CentralConfig::class, imports = [UUID::class])
interface SampleObjMapper {
    fun toDomain(sampleDto: SampleDto): SampleDomainObj
}

And we can include this new Mapper into the CoinGeckoDtoMapper:

@Mapper(config = CentralConfig::class, imports = [ArrayList::class], uses = [SampleObjMapper::class])
interface CoinGeckoDtoMapper {
    @Mappings(
        ...
        Mapping(target = "sample", source = "sampleDto")
    )
    fun toDomain(assetDto: CoinGeckoCryptoAssetDto): CryptoAsset
}

Generating that, we will receive the following:

@Component
public class CoinGeckoDtoMapperImpl implements CoinGeckoDtoMapper {

    private final SampleObjMapper sampleObjMapper; // beautifully injected through constructor and not field, as defined in the settings

    @Autowired
    public CoinGeckoDtoMapperImpl(SampleObjMapper sampleObjMapper) {

        this.sampleObjMapper = sampleObjMapper;
    }

    @Override
    public CryptoAsset toDomain(CoinGeckoCryptoAssetDto assetDto) {
        if ( assetDto == null ) {
            return null;
        }
        ...
        
        SampleDomainObj sample = null;
        sample = sampleObjMapper.toDomain( assetDto.getSampleDto() );
        
        ...

        CryptoAsset cryptoAsset = new CryptoAsset( id, symbol, name, marketCapRank, latestPrice, price, priceHistory, sample, positions );

        return cryptoAsset;
    }
}

The generated SampleObjMapperImpl looks as follows (but this is almost irrelevant, as we trust mapstruct by now):

@Component
public class SampleObjMapperImpl implements SampleObjMapper {

    @Override
    public SampleDomainObj toDomain(SampleDto sampleDto) {
        if ( sampleDto == null ) {
            return null;
        }

        UUID id = null;
        String name = null;

        id = sampleDto.getId();
        name = sampleDto.getName();

        SampleDomainObj sampleDomainObj = new SampleDomainObj( id, name );

        return sampleDomainObj;
    }
}

I have to admit that there are sometimes some minor inconveniences with Kotlin functionalities not reflected as such in Java, however, in the vast majority of cases, mapstruct is a great tool to quickly, and safely, set up your mappers!

Well, there you have it! You are now fully equipped to use mapstruct in your Kotlin projects, and say good-bye to tedious writing of boilerplate code! I hope this guide will serve you to not go too crazy on getting items to run! :)