All Articles

Advanced mappings with mapstruct in Kotlin

Mapstruct
Mapstruct, while very convenient, can be complicated in Kotlin

In the previous article, I have introduced mapstruct for the mapping of DTOs to domain objects and back. A lot of ground was already covered, and most usecases will not require any other features.

However, in this article, we will cover two additional items that will really give you enormous freedom and remove any last objections to using mapstruct, as it will enable you to write code to cover even the most complex cases.

Expression limitations

In the article mentioned above, the expression is introduced. This is really a cool option, as it allows us to write small code snippets for mappings that require some alterations. An example is here:

@Mapping(target = "symbol", expression = "java(assetDto.getSymbol().toUpperCase())")

However, while you can technically write complicated code in there, there are some reasons to avoid this:

  1. It is all in Java. The reason you’re using Kotlin probably means that you don’t want to do too much in Java.
  2. It is error-prone. Since you’re using Strings, it is easy to make mistakes.

An example of point 2 is the following error I got during compilation of :

@Mapping(target = "symbol", expression = "java(assetDto.getSymbol().toUpperCase()")

The error message wasn’t all that helpful…

Compilation error
Funnily enough, this is not showing a missing parenthesis...
The error was actually simply a missed closing parenthesis. This will happen, as we do not have the IDE helping out with such topics.

qualifiedByName

In order to avoid both problems above, we want to extract some logic into separate methods. Fortunately, this is possible.

As already presented in the previous article, if there are different types to be mapped, this is easily done. When there are no special conversions however, it is not that straightforward.

The solution is the qualifiedByName option in the @Mapping annotation, accompanied by a function that can be written entirely in Kotlin, and can be as long as you want!

An example:

    @Mappings(
        Mapping(target = "assetSymbol", source = "asset.symbol"),
        Mapping(target = "value", source = ".", qualifiedByName = ["getValue"]),
    )
    abstract fun toDto(position: CryptoPosition): CryptoPositionDto
    
    @Named(value = "getValue")
    fun getValue(position: CryptoPosition): ValueDto {
        return ValueDto(position.amount * position.asset.latestPrice)
    }

The resulting Mapper will contain the following callback to the getValue function:

@Override
    public CryptoPositionDto toDto(CryptoPosition position) {
        if ( position == null ) {
            return null;
        }

        ValueDto value = null;
        value = getValue( position );

        CryptoPositionDto cryptoPositionDto = new CryptoPositionDto( id, name, amount, value, description, addedOn, editedOn, assetSymbol, positionSnapshots );

        return cryptoPositionDto;
    }

The rest of the code has been omitted for brevity. However, it can be clearly seen that, if a mapping with some additional logic is required, it can very well be done in the language you’re most comfortable with.

Repository injection

If a mapper requires some help from a different Mapper, this can be easily accomplished with the uses part of the annotation. This also applies for other Components.

@Mapper(config = CentralConfig::class, imports = [ArrayList::class], uses = [CryptoPositionSnapshotMapper::class])
// generates
@Autowired
public CryptoPositionMapperImpl(CryptoPositionSnapshotMapper cryptoPositionSnapshotMapper) {
    this.cryptoPositionSnapshotMapper = cryptoPositionSnapshotMapper;
}

For repositories (and interfaces in general) though, I have not been able to get this to work the same way.

So, the solution is to use the three steps below:

  1. Using Injection (though unfortunately it’s field injection) by going over an abstract class instead of an interface
  2. Make the mapping methods abstract as well.
  3. Use the @Named annotation to use the injected bean to define your functionality
@Mapper(config = CentralConfig::class, imports = [ArrayList::class], uses = [CryptoPositionSnapshotMapper::class])
abstract class CryptoPositionMapper {  //1)

    @Autowired                          //1)
    private lateinit var cryptoAssetRepository: CryptoAssetRepository

    @Mappings(
        Mapping(target = "asset", source = "assetSymbol", qualifiedByName = ["getAsset"]),
        ...
    )
    abstract fun toDomain(cryptoPositionDto: CryptoPositionDto): CryptoPosition //2)

    @Named(value = "getAsset")          //3)
    fun getAsset(assetSymbol: String): CryptoAsset? {
        return this.cryptoAssetRepository.findBySymbol(assetSymbol)
    }

Having done all this, the generated mapper will not show much of what is defined above, but really only contain the following line:

asset = getAsset( cryptoPositionDto.getAssetSymbol() );
// which, if drilled down on, will go to the custom definition we wrote ourselves

There you go, you now have all the tools to map even that one, tricky field that was impossible to get right with mapstruct!