Mapping data between layers
If you don't understand the importance of using different models per layer continue reading. I will use android examples for explain different concepts, but they apply for any system.
Clean Architecture approach
First of all, picking a software architecture could be difficult for different reasons. Most developers choose a technology and then start developing without considering different concepts such as scalability, share modules, or even readability. The next Clean Architecture approach was created in a team of developers after a long research and examples of clean architecture, onion architecture, domain driven design, etc. A fast overview of each layer could be separate the concepts in three layers:
Presentation Layer
This layer is in charge of handle the UI, or handle the input / output from the client, depending the system you are developing. For example, The Activity, Fragments and ViewModels on Android.
Domain Layer
Contains the components that understand the business logic. They provides abstraction of the business logic and communicates with the data layers through the data sources. Examples: Use cases, repositories, etc.
Data Layer
Finally, this layer provides the abstraction of the different data sources. For example: Data base communication, APIs, Local storage, etc.
The God Model
Divide your project in layers could seem difficult when is your first time, but you will come to understand all the advantages of using a well divided architecture. But, the most complicated is to defining the models. Of course, the models are easy to create, but you need to decide:
- If you define the models fast, your models will suffer several updates during the software lifecycle.
- If you take your time to well define your models, It will allow you to create a scalable software.
Now, you could be tempted to create the models that all your layers share, after all, what could go wrong? Lets take a look to the next example:
// Model
data class User(
val id: String,
val username: String,
)
// Presentation
class View(
private val userViewModel: UserViewModel
) {
fun displayUserData() {
val user = userViewModel.user
print(user)
}
}
class UserViewModel(
private val getUserUseCase: GetUserUseCase,
) {
val user: User get() = getUserUseCase()
}
// Domain
interface GetUserUseCase {
operator fun invoke(): User
}
class GetUserUseCaseImpl(
private val userRepository: UserRepository
) : GetUserUseCase {
override fun invoke(): User = userRepository.getUser()
}
interface UserRepository {
fun getUser(): User
}
class UserRepositoryImpl(
private val userDataSource: UserDataSource,
) : UserRepository {
override fun getUser(): User = userDataSource.getUser()
}
// Data
interface UserDataSource {
fun getUser(): User
}
class UserDataSourceImpl(
private val appDatabase: AppDatabase,
) : UserDataSource {
override fun getUser(): User {
return appDatabase.getUser()
}
}
interface AppDatabase {
fun getUser(): User
}
Everything looks great!, right? well, lets considere next problems:
- Imagine the data base should be modified because the user contains more data.
- Imagine you need to add a RESTFULL API data source.
- Imagine that the UI could not handle the data types that the data base use.
Every time you will need to modify your model, all your app need to be updated.
One Model per Layer
One solution could be creating a different model per layer. But it is not so simple, is it? Well, no, but with some different design patterns you will obtain a very well decoupled architecture. To solve our previous example we will use next interfaces:
interface Mapper<in I, out O> {
fun map(input: I): O
}
Every time you need to pass a model from one layer to another, create a mapper implementation to handle it.
interface ListMapper<in I, out O> : Mapper<List<I>, List<O>>
class ListMapperImpl<in I, out O>(
private val mapper: Mapper<I, O>
) : ListMapper<I, O> {
override fun map(input: List<I>): List<O> =
input.map(mapper::map)
}
If you need a list of models, considere to use this ListMapperImpl
class that receive a mapper to do the conversion.
So that, we can create next models:
// Presentation Model
data class User(
val id: String,
val username: String,
)
// Domain Model
data class DomainUser(
val id: String,
val username: String,
)
// Data Model
data class DataUser(
val id: String,
val username: String,
)
Each model have a different purpose:
Presentation Model
The main purpose of this model is to serve the UI, or input / output interface.
Every data in this model could represent a UI component. You could create a different model for different views. For example, a FormUse
or a CardUser
.
Domain Model
The domain model is ideal for containing the business logic. For example, imagine that you need a fullname for your user, but you need to store first name and last name separately. The properties could stay apart in case you need the first or last name.
data class DomainPerson(
val firstName: String,
val lastName: String,
) {
val fullName: String get() = "$firstName $lastName"
}
Data Model
Use this model to mimic the data source structure. Some times the data sources needs models properties names to be specific. Don't hesitate to name your data model exactly as the data sources needs.
data class DataUser(
val use_id: String,
val use_firstname: String,
val use_lastname: String,
)
Full example
Putting all together:
// Presentation Model
data class User(
val id: String,
val fullName: String,
)
// Domain Model
data class DomainUser(
val id: String,
val firstName: String,
val lastName: String,
) {
val fullName: String get() = "$firstName $lastName"
}
// Data Model
data class DataUser(
val use_id: String,
val use_firstname: String,
val use_lastname: String,
)
// Presentation
class View(
private val userViewModel: UserViewModel
) {
fun displayUserData() {
val user = userViewModel.user
print(user)
}
}
class UserViewModel(
private val getUserUseCase: GetUserUseCase,
private val domainPersonToUserMapper: DomainUserToUserMapper,
) {
val user: User get() = getUserUseCase().let(domainPersonToUserMapper::map)
}
class DomainUserToUserMapper : Mapper<DomainUser, User> {
override fun map(input: DomainUser): User = with(input) {
User(id, fullName)
}
}
// Domain
interface GetUserUseCase {
operator fun invoke(): DomainUser
}
class GetUserUseCaseImpl(
private val userRepository: UserRepository
) : GetUserUseCase {
override fun invoke(): DomainUser = userRepository.getUser()
}
interface UserRepository {
fun getUser(): DomainUser
}
class UserRepositoryImpl(
private val userDataSource: UserDataSource,
private val dataUserToDomainUserMapper: DataUserToDomainUserMapper,
) : UserRepository {
override fun getUser(): DomainUser = userDataSource.getUser().let(dataUserToDomainUserMapper::map)
}
class DataUserToDomainUserMapper : Mapper<DataUser, DomainUser> {
override fun map(input: DataUser): DomainUser = with(input) {
DomainUser(use_id, use_firstname, use_lastname)
}
}
// Data
interface UserDataSource {
fun getUser(): DataUser
}
class UserDataSourceImpl(
private val appDatabase: AppDatabase,
) : UserDataSource {
override fun getUser(): DataUser =
appDatabase.getUser()
}
interface AppDatabase {
fun getUser(): DataUser
}
interface Mapper<in I, out O> {
fun map(input: I): O
}
interface ListMapper<in I, out O> : Mapper<List<I>, List<O>>
class ListMapperImpl<in I, out O>(
private val mapper: Mapper<I, O>
) : ListMapper<I, O> {
override fun map(input: List<I>): List<O> =
input.map(mapper::map)
}
Easy? of course not, but you will arrive to get it after see the results of applying this methodology.
References
I let you some articles to read about.