Redundant DTO-Domain Mapping in Kotlin Flow

Florent Blot
6 min readJul 3, 2022
Photo by Ivan Bandura on Unsplash

Separating layers is a best practice to write high-quality code, to prevent errors and to avoid unexpected behavior inside projects.

When fetching data from an API, we often parse the result into DTO class. Using a Mapper, we transform it into Domain class. Then, in other layers, we only consume this Domain object. This ensures the correct isolation of data between architecture layers.

Within a flow, we use map to convert a DTO to a Domain object and consume it in downstream flows. We thought it might be elegant to avoid repeating these map operators doing the same simple thing. We thought we could create a custom operator and map DTO to Domain with it in one line.

Mapping DTO-Domain 🎭

In Kotlin Flow, this mapping can be done using map operator and a simple class extension.

In our DTO object, we should have all fields nullable. This can prevent an exception in case of a missing value from the API. For example, we could receive some user information and parse them into a DTO object as follows:

data class UserDto(val id: String? = null, val name: String? = null, val age: Int? = null)

On the other side, with our Domain object, we choose to produce a data class without any optional fields. This will avoid to deal with nullable properties all over our layers, also to prevent multiple checks with ? operators. Instead of checking for null, we can deal with default value. So we should use this data class:

data class User(val id: String, val name: String, val age: Int)

In a new Kotlin file, we make a DTO extension to build a Domain object from its data as a Mapper function:

fun UserDto.asDomain() = User(id = this.id.orEmpty(), name = this.name.orEmpty(), age = this.age ?: 0)

Then, we map it in our flow thanks to this above extension:

fetchUserData().map { return@map it.toDomain() }.collect { println(it) }

In the downstream flows, we can now use Domain object instead of DTO object.

Fetch, Map, Repeat 🏋️

In Geev’s android app, we like to isolate operations specifically and separate each action into a single operator.

We often end up repeating the same simple line on each flows we collect.

fetchUserData().map { return@map it.toDomain() }.onEach { … }.map { … }…; fetchStatsData().map { return@map it.toDomain() }.onEach { … }…; fetchArticleData().map { return@map it.toDomain() }.onStart { … }.onEach { … }…

This is a redundant task... Wouldn’t it be nicer to call .toDomain() directly and to deal with the Domain object in downstream flows, hiding these blocks redundancies?

Attempt #1: interfaces ✨

Transform allows us to create custom operator in Kotlin Flow.

This operator generalizes filter and map operators and can be used as a building block for other operators.

This is an extension of Flow and takes two generic types: T as initial value from a Flow<T> and R as final value in a Flow<R> from a FlowCollector<R>. It is created with a flow builder and applies the transform suspended action on the flow’s value.

https://github.com/Kotlin/kotlinx.coroutines/blob/master/kotlinx-coroutines-core/common/src/flow/operators/Emitters.kt#L19

By using this powerful extension, we can create our own operators. So we could use it and transform our object like this:

fun Flow<UserDto>.toDomain(): Flow<User> = transform { value -> return@transform emit(value.asDomain()) }

Note that we renamed the DTO extension toDomain to asDomain to avoid confusion with our custom flow’s operator.

With it, our flow process becomes less verbose and avoids unnecessary noise.

fun fetchUserData().toDomain().onEach { … }.map { … } …

While this is working, it is not generic, and can be used only for User’s flow. In order to create a generic transform function, we could create two interfaces and implement the asDomain function for DTO classes.

interface Dto { fun asDomain(): Domain }; interface Domain

So all our models should used these two interfaces, as these classes:

By modifying our custom operator to handle the interfaces instead of the models itself, we can now use this for all our flows.

fun Flow<Dto>.toDomain(): Flow<Domain> = transform { value -> return@transform emit(value.asDomain()) }

fetchUserData, fetchStatsData and fetchArticleData can use this flow’s extension and remove the redundant map operator. Great!

Pros:
- Force us to implement asDomain, then it should be never forget.
- It is generic and works with every class which has
Dto and Domain.

Cons:
- The mapping function is inside the DTO class, increasing the noise.
- ALL data classes will have to implement this interface.

Attempt #2: extensions 💫

Although the previous attempt succeed, we cannot separate the mapping function from its class owner in a respective file, it is not possible with an interface.

To keep our asDomain function separated from its class declaration, we could simply apply the same logic to the flow: have a custom extension of the flow.

Then our Mapper file, which already includes the DTO function, will also contain a flow extension.

fun UserDto.asDomain() = User(id = this.id.orEmpty(), name = this.name.orEmpty(), age = this.age ?: 0);  fun Flow<UserDto>.toDomain(): Flow<User> = map { return@map it.asDomain() }

We need a specific extension function for each DTO’s flows and we do not need to do it with transform, a simple map as before works well.

Then each Mapper files hold two functions: the DTO mapping to Domain function, and the flow custom extension to call it. Okay!

Pros:
- Keep the mapping functions inside a specific file.
- Easy to use and to understand.

Cons:
- Almost all flows will have to create an extension.

Attempt #3: generics ⭐️

The second attempt also works. However, we do not felt confortable to create an extension function for each of our flows. Why can’t we use a single flow extension?

We could create only one flow’s extension for all objects (Any), pass the class type we need to retrieve, check with a when statement what is the entered class type and do the asDomain function if it suits.

We can also add an else branch if the type class found is not referenced here. This will throw an error and force us to implement the mapping function for the type class.

Then, we could rewrite our flows to use this single extension and passing the expected Domain class in it.

fetchUserData().toDomain<User>().onEach { … }.map { … }…; fetchStatsData().toDomain<Stats>().onEach { … }…; fetchArticleData().toDomain<Article>().onStart { … }.onEach { … }…

This does the job: only one extension function for all, can be used with every flow, clears the process a little and easy to implement. Yes!

Pros:
- Force to implement it otherwise, this throws an exception.

- Clear a little the process for all flows.

Cons:
- Hard to maintain when it becomes to more than three classes.
- Must to write the class name into the flow process: less readable.

Results ⛳️

We tried to implement each of our attempts, they all have pros and cons. We do not found the clear way to do it, so we decide to stick with the simple map operator. However, this is clearly a nice try and we enjoyed found other ways to do our mapping data. What about you, how do you map your DTO to Domain object in Kotlin Flow?

If you found this post helpful, feel free to clap! 👏 Thanks for reading.

--

--

Florent Blot

Maker, Mobile developer, Kotlin & Dart enthusiast, IoT & DIY addict. @Geev: https://www.geev.com