[Kotlin] Using Mappie to Reassign Data Class Properties
Motivation When using a layered architecture, we inevitably have to repopulate data classes frequently. I’ve grown quite accustomed to this, so it doesn’t bother me much anymore... but every once in a while, I wonder if there’s a more effortless way to handle it. The most classic approach is to use a reflection-based library, but reflection introduces overhead, and more importantly, it’s not type-safe, which is undesirable. (For example, if there’s a field that cannot be mapped, it might be silently ignored, causing unexpected bugs...) In Java, there is MapStruct which uses annotation processing. However, it seems somewhat incompatible with Kotlin. While searching for similar libraries, I found Mappie, which looked promising, so I’d like to introduce it here. (Incidentally, there’s also konvert. It appears quite similar to MapStruct in terms of usage, but because it’s annotation-based, it might lack flexibility. Hence, this time I’ll introduce Mappie.) Overview of Mappie Mappie is implemented as a Kotlin Compiler Plugin, and it automatically generates object mapping code at compile time wherever possible. First, let’s look at a simple example. By just preparing a PersonMapper that extends ObjectMappie, the code to convert from Person to PersonDto is automatically generated. data class Person( val firstName: String, val lastName: String, val middleName: String?, val age: Int, ) data class PersonDto( val firstName: String, val lastName: String, val age: Int, ) // HERE object PersonMapper : ObjectMappie() fun main() { val person = Person(firstName = "Ryosuke", lastName = "Hasebe", middleName = null, age = 20) val personDto = PersonMapper.map(person) println(personDto) } // Result PersonDto(firstName=Ryosuke, lastName=Hasebe, age=20) You Can Also Explicitly Specify Mapping Rules In the previous example, both classes had fields with the same names, so automatic mapping code could be generated. However, consider a case where PersonDto.name cannot be mapped. This leads to a compile error, which is great because it prevents silent failures. (Unfortunately, it didn’t show up as a compile error in IntelliJ IDEA... but perhaps improvements to the FIR implementation could address that in the future.) data class Person( val firstName: String, val lastName: String, val age: Int, ) data class PersonDto( val name: String, val age: Int, ) object PersonMapper : ObjectMappie() fun main() { val person = Person(firstName = "Ryosuke", lastName = "Hasebe", age = 20) val personDto = PersonMapper.map(person) println(personDto) } // Compile Error Target PersonDto::name has no source defined In such scenarios, you can resolve the issue by explicitly writing out the mapping rules. Since these rules are written in a Kotlin DSL, it seems quite flexible. data class Person( val firstName: String, val lastName: String, val age: Int, ) data class PersonDto( val name: String, val age: Int, ) object PersonMapper : ObjectMappie() { // HERE override fun map(from: Person) = mapping { to::name fromValue "${from.firstName} ${from.lastName}" } } fun main() { val person = Person(firstName = "Ryosuke", lastName = "Hasebe", age = 20) val personDto = PersonMapper.map(person) println(personDto) } // Result PersonDto(name=Ryosuke Hasebe, age=20) From the documentation site, you can see that there are many other ways to define mapping rules: https://mappie.tech/ Expressing Mapping Errors Handling Them as Exceptions Let’s consider a scenario in which object creation involves a validation step. Naturally, if the validation fails, an exception is thrown. data class Person( val firstName: String, val lastName: String, val age: Int, ) data class PersonDto( val name: String, val age: Int, ) { // HERE init { require(age >= 20) { "age must be greater than or equals to 20" } } } object PersonMapper : ObjectMappie() { override fun map(from: Person) = mapping { to::name fromValue "${from.firstName} ${from.lastName}" } } fun main() { val person = Person(firstName = "Ryosuke", lastName = "Hasebe", age = 18) val personDto = PersonMapper.map(person) println(personDto) } // Result Exception in thread "main" java.lang.IllegalArgumentException: age must be greater than or equals to 20 at demo.PersonDto.(Main.kt:19) at demo.PersonMapper.map(Main.kt:24) at demo.MainKt.main(Main.kt:31) at demo.MainKt.main(Main.kt) Handling Them with kotlin.Result If you’d prefer to handle them with kotlin.Result rather than exceptions, you can accomplish this by simply writing an extension function, as shown below. This flexibility is really convenient. fun ObjectMappie.mapAsResult(from: FROM): Result { return runCatching { map(from) } } fun main(
![[Kotlin] Using Mappie to Reassign Data Class Properties](https://media2.dev.to/dynamic/image/width%3D1000,height%3D500,fit%3Dcover,gravity%3Dauto,format%3Dauto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fsd0rmjdifmjtbkklucmy.png)
Motivation
When using a layered architecture, we inevitably have to repopulate data classes frequently.
I’ve grown quite accustomed to this, so it doesn’t bother me much anymore... but every once in a while, I wonder if there’s a more effortless way to handle it.
The most classic approach is to use a reflection-based library, but reflection introduces overhead, and more importantly, it’s not type-safe, which is undesirable.
(For example, if there’s a field that cannot be mapped, it might be silently ignored, causing unexpected bugs...)
In Java, there is MapStruct which uses annotation processing. However, it seems somewhat incompatible with Kotlin.
While searching for similar libraries, I found Mappie, which looked promising, so I’d like to introduce it here.
(Incidentally, there’s also konvert. It appears quite similar to MapStruct in terms of usage, but because it’s annotation-based, it might lack flexibility. Hence, this time I’ll introduce Mappie.)
Overview of Mappie
Mappie is implemented as a Kotlin Compiler Plugin, and it automatically generates object mapping code at compile time wherever possible.
First, let’s look at a simple example. By just preparing a PersonMapper
that extends ObjectMappie
, the code to convert from Person
to PersonDto
is automatically generated.
data class Person(
val firstName: String,
val lastName: String,
val middleName: String?,
val age: Int,
)
data class PersonDto(
val firstName: String,
val lastName: String,
val age: Int,
)
// HERE
object PersonMapper : ObjectMappie<Person, PersonDto>()
fun main() {
val person = Person(firstName = "Ryosuke", lastName = "Hasebe", middleName = null, age = 20)
val personDto = PersonMapper.map(person)
println(personDto)
}
// Result
PersonDto(firstName=Ryosuke, lastName=Hasebe, age=20)
You Can Also Explicitly Specify Mapping Rules
In the previous example, both classes had fields with the same names, so automatic mapping code could be generated.
However, consider a case where PersonDto.name
cannot be mapped. This leads to a compile error, which is great because it prevents silent failures.
(Unfortunately, it didn’t show up as a compile error in IntelliJ IDEA... but perhaps improvements to the FIR implementation could address that in the future.)
data class Person(
val firstName: String,
val lastName: String,
val age: Int,
)
data class PersonDto(
val name: String,
val age: Int,
)
object PersonMapper : ObjectMappie<Person, PersonDto>()
fun main() {
val person = Person(firstName = "Ryosuke", lastName = "Hasebe", age = 20)
val personDto = PersonMapper.map(person)
println(personDto)
}
// Compile Error
Target PersonDto::name has no source defined
In such scenarios, you can resolve the issue by explicitly writing out the mapping rules. Since these rules are written in a Kotlin DSL, it seems quite flexible.
data class Person(
val firstName: String,
val lastName: String,
val age: Int,
)
data class PersonDto(
val name: String,
val age: Int,
)
object PersonMapper : ObjectMappie<Person, PersonDto>() {
// HERE
override fun map(from: Person) = mapping {
to::name fromValue "${from.firstName} ${from.lastName}"
}
}
fun main() {
val person = Person(firstName = "Ryosuke", lastName = "Hasebe", age = 20)
val personDto = PersonMapper.map(person)
println(personDto)
}
// Result
PersonDto(name=Ryosuke Hasebe, age=20)
From the documentation site, you can see that there are many other ways to define mapping rules:
Expressing Mapping Errors
Handling Them as Exceptions
Let’s consider a scenario in which object creation involves a validation step. Naturally, if the validation fails, an exception is thrown.
data class Person(
val firstName: String,
val lastName: String,
val age: Int,
)
data class PersonDto(
val name: String,
val age: Int,
) {
// HERE
init {
require(age >= 20) { "age must be greater than or equals to 20" }
}
}
object PersonMapper : ObjectMappie<Person, PersonDto>() {
override fun map(from: Person) = mapping {
to::name fromValue "${from.firstName} ${from.lastName}"
}
}
fun main() {
val person = Person(firstName = "Ryosuke", lastName = "Hasebe", age = 18)
val personDto = PersonMapper.map(person)
println(personDto)
}
// Result
Exception in thread "main" java.lang.IllegalArgumentException: age must be greater than or equals to 20
at demo.PersonDto.<init>(Main.kt:19)
at demo.PersonMapper.map(Main.kt:24)
at demo.MainKt.main(Main.kt:31)
at demo.MainKt.main(Main.kt)
Handling Them with kotlin.Result
If you’d prefer to handle them with kotlin.Result
rather than exceptions, you can accomplish this by simply writing an extension function, as shown below. This flexibility is really convenient.
fun <FROM, TO> ObjectMappie<FROM, TO>.mapAsResult(from: FROM): Result<TO> {
return runCatching { map(from) }
}
fun main() {
val person = Person(firstName = "Ryosuke", lastName = "Hasebe", age = 18)
val personDtoResult = PersonMapper.mapAsResult(person)
println(personDtoResult.getOrNull())
}
// Result
null
Conclusion
By leveraging Mappie, you can implement object mapping in Kotlin in a concise and type-safe manner.
In addition to the default automatic mapping, you can specify flexible mapping rules via a DSL, which supports more fine-grained control when needed. Furthermore, for error handling during mapping, you can choose to handle errors as exceptions or use kotlin.Result
, offering a flexible design.
It’s still debatable whether it’s ready for production use, but it certainly seems very convenient.