DEV Community 👩‍💻👨‍💻

Cover image for Object Mapping advanced features & QoL with Kotlin
Aviv Mor for krud.dev

Posted on

Object Mapping advanced features & QoL with Kotlin

When working with multi-layered applications, external libraries, a legacy code base or external APIs, we are often required to map between different objects or data structures.

In this tutorial, we will check out some Object Mapping libraries advanced features to simplify this task while saving development and maintenance time.

In our examples we will use the library ShapeShift. It's a light-weight object mapping library for Kotlin/Java with lots of cool features.

Auto Mapping

We will start with a bang. Auto mapping can and will save you lots of time and boiler-plate code. Some applications require manual mapping between objects, but most applications will save tons of time working on boring boiler-plate by just using this one feature. And it gets even better, with ShapeShift's default transformers we can even use automatic mapping between different data types.

Simple Mapping

Let's start with a simple example for auto mapping. We have our two objects, imagine they could also have tens, hundreds, or even thousands (for the crazy people here) of fields.

class User {
    var id: String = ""
    var name: String? = null
    var email: String? = null
    var phone: String? = null
}

class UserDTO {
    var id: String = ""
    var name: String? = null
    var email: String? = null
    var phone: String? = null
}
Enter fullscreen mode Exit fullscreen mode

We want to map all the field from User to UserDTO. Using auto mapping we don't need to write any boiler-plate code. The mapper will be defined as follow:

val mapper = mapper<User, UserDTO> {
    autoMap(AutoMappingStrategy.BY_NAME_AND_TYPE)
}
Enter fullscreen mode Exit fullscreen mode

Voila! All the fields will be mapped automatically without any manual boiler-plate code.

Advanced Mapping

In this example we will use the power of default transformers to take auto mapping even further.

class User {
    var id: String = ""
    var name: String? = null
    var birthDate: Date? = null
}

class UserDTO {
    var id: String = ""
    var fullName: String? = null
    var birthDate: Long? = null
}
Enter fullscreen mode Exit fullscreen mode

Note that the types of the birthDate field are different in the source and destination classes. But using the power of default transformers we can still use auto mapping here.

val mapper = mapper<User, UserDTO> {
    autoMap(AutoMappingStrategy.BY_NAME)
}
Enter fullscreen mode Exit fullscreen mode

We changed the auto mapping strategy to BY_NAME so it will map fields also with different types. Now we need to register a default transformer to the ShapeShift instance in order for it to know how to transform Date to Long.

val shapeShift = ShapeShiftBuilder()
    .withTransformer(DateToLongMappingTransformer(), true)
    .build()
Enter fullscreen mode Exit fullscreen mode

We can also add manual mapping on top of the auto mapping in order to add/change behavior. The source and destination classes have different names for the name field so we will add manual mapping for it.

val mapper = mapper<User, UserDTO> {
    autoMap(AutoMappingStrategy.BY_NAME)
    User::name mappedTo UserDTO::fullName
}
Enter fullscreen mode Exit fullscreen mode

Auto mapping is great for use cases that does not require specific mapping. It helps reduce the amount manual boiler-plate code needed to configure mapping and also helps you keep your sanity.

Transformers

Transformers are very useful feature that allows you to transform the type/value of a field to a different type/value when mapping a field.

Some use cases we have been using widely:

  • Transform date to long and vice versa between server and client objects.
  • Transform JSON string to it's actual type and vice versa between server and client objects.
  • Transform comma separated string to list of enums.
  • Transform another object id to its object or one of its fields from the DB using Spring transformers.

Basic Transformers

We will start with a simple transformer example. Date-to-Long and Long-to-Date transformers:

class DateToLongMappingTransformer : MappingTransformer<Date, Long> {
    override fun transform(context: MappingTransformerContext<out Date>): Long? {
        return context.originalValue?.time
    }
}

class LongToDateMappingTransformer : MappingTransformer<Long, Date> {
    override fun transform(context: MappingTransformerContext<out Long>): Date? {
        context.originalValue ?: return null
        return Date(context.originalValue)
    }
}
Enter fullscreen mode Exit fullscreen mode

All we need to do now is to register them.

val shapeShift = ShapeShiftBuilder()
    .withTransformer(DateToLongMappingTransformer(), true) // "true" is optional, we are registering the transformers as default transformers, more on that later.
    .withTransformer(LongToDateMappingTransformer(), true)
    .build()
Enter fullscreen mode Exit fullscreen mode

That's it! We can now use the transformers when mapping objects.

class User {
    var id: String = ""
    var name: String? = null
    var birthDate: Date? = null
}

class UserDTO {
    var id: String = ""
    var name: String? = null
    var birthDate: Long? = null
}

val mapper = mapper<User, UserDTO> {
    User::id mappedTo UserDTO::id
    User::name mappedTo UserDTO::name
    User::birthDate mappedTo UserDTO::birthDate withTransformer DateToLongMappingTransformer::class // We don't have to state the transformer here because it is a default transformer
}
Enter fullscreen mode Exit fullscreen mode

Inline Transformers

In some use cases we want to transform the value but we don't need a reusable transformer and we don't want to create a class just for a one time use.

Inline transformers for the rescue! Inline transformers allow to transform the value without the need to create and register and transformer.

val shapeShift = ShapeShiftBuilder()
        .withMapping<Source, Target> {
            // Map birthDate to birthYear with a transformation function
            Source::birthDate mappedTo Target::birthYear withTransformer { (originalValue) ->
                originalValue?.year
            }
        }
        .build()
Enter fullscreen mode Exit fullscreen mode

Advanced Transformers

Transformers also allow us to do transformations with the DB or other data sources.

In this example we will use the power of the Spring Boot integration to create transformers with DB access.

We have three models:

  • Job - DB entity.
  • User - DB entity.
  • UserDTO - Client model.
class Job {
    var id: String = ""
    var name: String = ""
}

class User {
    var id: String = ""
    var jobId: String? = null
}

class UserDTO {
    var id: String = ""
    var jobName: String? = null
}
Enter fullscreen mode Exit fullscreen mode

We want to convert the jobId on User to jobName on UserDTO by querying the job from the DB and setting it on the DTO.

In Spring's case, you generally avoid interaction with the application context from static functions or functions on domain objects.

We will use a ShapeShift's Spring integration to create a component as a transformer to access our DAO bean.

@Component
class JobIdToNameTransformer(
    private val jobDao: JobDao
) : MappingTransformer<String, String>() {
    override fun transform(context: MappingTransformerContext<out String>): String? {
        context.originalValue ?: return null
        val job = jobDao.findJobById(context.originalValue!!)
        return job.name 
    }
}
Enter fullscreen mode Exit fullscreen mode

All that's left to do is to use this transformer in our mapping.

val mapper = mapper<User, UserDTO> {
    User::id mappedTo UserDTO::id
    User::jobId mappedTo UserDTO::jobName withTransformer JobIdToNameTransformer::class
}
Enter fullscreen mode Exit fullscreen mode

Another bonus of using transformers is their reusability. In some use cases, We could create more generic transformers that will have application-wide usage.

Default Transformers

When registering transformers you can indicate wether a transformer is a default transformer. A default transformer of types <A, B> is used when you map a field of type <A> to field of type <B> without specifying a transformer to be used.

As we already seen, default transformers are useful for recurring transformations and especially for automatic mapping.

Deep Mapping

What if we want to map from/to fields that available inside a field which is an object? We can even do that, easily.

In order to access child classes we can use the .. operator. Let's look at the following example.

class From {
    var child: Child = Child()

    class Child {
        var value: String?
    }
}

class To {
    var childValue: String?
}
Enter fullscreen mode Exit fullscreen mode

We want to map the value field in Child class inside the From class to the childValue field in the To class. We will create a mapper with the .. operator.

val mapper = mapper<From, To> {
    From::child..From.Child::value mappedTo To::childValue
}
Enter fullscreen mode Exit fullscreen mode

Let's take it one step further with multi level depth.

class From {
    var grandChildValue: String?
}

class To {
    var child: Child = Child()

    class Child {
        var grandChild: GrandChild = GrandChild()
    }

    class GrandChild {
        var value: String?
    }
}
Enter fullscreen mode Exit fullscreen mode

To access the grand child field we just use the .. operator twice.

val mapper = mapper<From, To> {
    From::grandChildValue mappedTo To::child..To.Child::grandChild..To.GrandChild::value
}
Enter fullscreen mode Exit fullscreen mode

Conditional Mapping

Conditions allow us to add a predicate to a specific field mapping to determine whether this field should be mapped.

Using this feature is as easy as creating a condition.

class NotBlankStringCondition : MappingCondition<String> {
    override fun isValid(context: MappingConditionContext<String>): Boolean {
        return !context.originalValue.isNullOrBlank()
    }
}
Enter fullscreen mode Exit fullscreen mode

And adding the condition to the desired field mapping.

data class SimpleEntity(
    val name: String
)

data class SimpleEntityDisplay(
    val name: String = ""
)

val mapper = mapper<SimpleEntity, SimpleEntityDisplay> {
    SimpleEntity::name mappedTo SimpleEntityDisplay::name withCondition NotBlankStringCondition::class
}
Enter fullscreen mode Exit fullscreen mode

Inline Conditions

Like transformers, conditions can also be added inline using a function.

val mapper = mapper<SimpleEntity, SimpleEntityDisplay> {
    SimpleEntity::name mappedTo SimpleEntityDisplay::name withCondition {
        !it.originalValue.isNullOrBlank()
    }
}
Enter fullscreen mode Exit fullscreen mode

Annotations Mapping

This specific feature receives lots of hate because it breaks the separation of concerns principle. Agreed, this could be an issue in some applications, but in some use cases where all objects are part of the same application it can also be very useful to configure the mapping logic on top of the object. Check out the documentation and decide for yourself.

Conclusion

Object mapping libraries are not the solution for every application. For small, simple applications using boiler-plate mapping functions are more than enough. But, when developing larger, more complex applications, object mapping libraries can take your code to the next level, saving you development and maintenance time. All of these while reducing the amount of boiler-plate code and overall improving the development experience.

On a personal note, I used to work with manual mapping functions and was ok with it. It was "just" some simple lines of code. After upgrading our applications to use object mapping as part of our "boiler-plate free" framework (We will discuss that framework at a later time), I can't go back. Now we spend more time on what's important and interesting and almost no time on boring boiler-plate code.

Top comments (0)

🌚 Browsing with dark mode makes you a better developer.

It's a scientific fact.