What is Object Mapping?
Multi-layered applications often have similar but different object models, where the data in two models may be similar but the structure and concerns of the models are different.
Writing the mapping code is a tedious and error-prone task. Object mapping makes it easy to convert one model to another.
Simple Example
Let's say we have a REST API application with two models. One for the server and one client facing:
User (Server):
data class User(
val id: String,
val name: String,
val email: String,
// isBlocked is server side only
val isBlocked: Boolean
)
UserDTO (Client):
data class UserDTO(
val id: String,
val name: String,
val email: String
)
Conversion
We have a couple of options:
Extension Methods
We can use simple extension methods.
fun User.toUserDTO(): UserDTO {
return UserDTO(id, name, email)
}
Simple and easy, but requires additional boiler-plate code we need to maintain every time we change the model.
Object Mapping
We will use the open source object mapping library ShapeShift for the examples.
Annotations
The first step is to add the annotations to the fields we want to map.
@DefaultMappingTarget(UserDTO::class)
data class User(
@MappedField
var id: String = "",
@MappedField
var name: String = "",
@MappedField
var email: String = "",
// isBlocked is server side only and not mapped to client DTO
var isBlocked: Boolean = false
)
All that's left is to convert.
val shapeShift = ShapeShiftBuilder().build()
val user = User("xyz", "john doe", "john@email.com", false)
val userDTO = shapeShift.map<UserDTO>(user)
DSL
In some cases we can't change the data classes code (or don't want to). For these use cases we create a standalone mapper between the two classes using Kotlin DSL.
val mapper = mapper<User, UserDTO> {
User::id mappedTo UserDTO::id
User::name mappedTo UserDTO::name
User::email mappedTo UserDTO::email
}
And now the conversion. Note that we are registering the mapper to the ShapeShift instance.
val shapeShift = ShapeShiftBuilder().withMapping(mapper).build()
val user = User("xyz", "john doe", "john@email.com", false)
val userDTO = shapeShift.map<UserDTO>(user)
Seems like a lot of work for a simple conversion, right? Maybe... Let's continue to the next example.
Auto Mapping
What if I told you there is a way to remove almost if not all the mapping boiler-plate code? In many use cases we have similar classes we need to map between. This is where automatic mapping comes to the rescue.
Let's continue with the same classes.
data class User(
val id: String,
val name: String,
val email: String,
val isBlocked: Boolean
)
data class UserDTO(
val id: String,
val name: String,
val email: String
)
Using automatic mapping our mapper will be the following.
val mapper = mapper<User, UserDTO> {
autoMap(AutoMappingStrategy.BY_NAME_AND_TYPE)
}
Voila! No boiler-plate code to configure fields manually. Auto mapping also comes with advanced features for more complex use cases.
Generic Example
When our projects start to scale, we want to add generic functionality to improve productivity and decrease maintenance. For example:
- Generic CRUD controller for entities.
- Generic export to excel/pdf/...
In order for us to be able to implement these generic functionality we need to be able to convert our objects back and forth between classes.
We can use the factory method pattern to do it, simple and straightforward, but agin requires more boiler-plate code we need to maintain when we add new models.
Another option is (surprise surprise) to use object mapping.
inline fun <reified ExportModel : Any> export(model: Any) {
val shapeShift = ShapeShiftBuilder().build()
val exportModel = shapeShift.map<ExportModel>(model)
// export logic...
}
val user = User(/*...*/)
export<UserExport>(user)
That's it. Zero boiler-plate code and we got ourselves the generic functionality we know and love.
Type Safety
Let's take it further and add type safety to the export method. For that we will add two interfaces:
interface ExportModel {
}
interface BaseModel<EM: ExportModel> {
}
Now we will update our classes to implement the interfaces.
class UserExport: ExportModel {
// ...
}
class User: BaseModel<UserExport> {
// ...
}
And change our generic method respectively.
inline fun <reified EM : ExportModel> export(model: BaseModel<EM>) {
val shapeShift = ShapeShiftBuilder().build()
val exportModel = shapeShift.map<EM>(model)
// export logic...
}
That's it. We got ourselves type safe generic export method.
val user = User(/*...*/)
export(user)
Spring Example
Taking it up a notch to the next level. We have three models.
- Job - DB entity.
- User - DB entity.
- UserDTO - Client model.
@Entity
@Table(name = "jobs")
class Job {
var id: String = ""
var name: String = ""
}
@Entity
@Table(name = "users")
class User {
var id: String = ""
var jobId: String? = null
}
class UserDTO {
var id: String = ""
var jobName: String? = null
}
We need 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 and because of that they cannot be invoked from extensions.
Object mapping to the rescue (surprise!). Let's create a custom transformer to do the heavy lifting for us.
@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
}
}
The complicated part is over. Now we just need to add the annotations to the User model.
@Entity
@Table(name = "users")
@DefaultMappingTarget(UserDTO::class)
class User {
@MappedField
var id: String = ""
@MappedField(transformer = JobIdToNameTransformer::class, mapTo = "jobName")
var jobId: String? = null
}
That's all. The great part is that we can use the same transformer every time we need to convert a job id to job name on any model.
val shapeShift = ShapeShiftBuilder().build()
val user = User(/*...*/)
val userDTO = shapeShift.map<UserDTO>(user)
A full spring example code is available here.
Pros & Cons
Each conversion method has its own pros & cons.
Extension Methods
Pros
- Simple implementation.
- Clear code - no annotations "magic".
Cons
- Lots of boiler plate code.
- Limited functionality.
- Does not scale well.
Object Mapper
Pros
- Little to none boiler-plate code.
- Auto mapping.
- Generic code.
- Advanced features.
- Code reuse.
Cons
- Requires to learn new library.
- Annotations "magic".
Summary
Like most things in development, there is no right answer here. The answer is that it depends on the project. For small simple projects extension methods are more than enough to convert classes, but for large enterprise projects using an object mapper can give the freedom to achieve better architecture and more generic code that can keep moving forward with large codebase and lots of models.
Top comments (0)