This is a series of articles about how to architecture your app that it was inspired by Google Guide to App Architecture and my personal experience.
What architecture to choose for the project? A common question for many engineers and often the choice is to follow Clean Architecture principles. Put the code in different layers, usually, it’s Data, Domain, and Presentation. Each of the layers has its components logic, and data models. One of the main ideas of different layers is to have a dependency hierarchy and in the context of that today, I’ll show you how to map data to transfer through the layers.
Let’s assume we have the dependency hierarchy as in the picture below and zero in on each layer of data mapping.
Naming conventions
Before we shed the light on each layer, let’s agree on naming conventions. The mapping functions are named after the layer data type. We reserve prefixes for specific functions that map data model representation between layers. The convention is as follows:
as + layer data type.
For example, we have CategoryDTO
, Category
, CategoryModel
for Data, Domain, and Presentation layers respectably. The mapping function’s names will be asDTO
, asDomain
, asPresentation
respectively.
If you need to transform one type to another we reserve prefix to. The convention is as follows:
to + data type.
For example, CategoryModel
has adsCount
property that we want to map to UI string representation, and create the toAdsCountFormat
function.
fun Int?.toAdsCountFormat(): String? = | |
if (this == null) null else FormatUtils.getDecimalNumber(this) |
The difference between prefixes as and to is that use as for name when we map the same model that has representation in each layer and to for function to map one type to complete a different one.
Data layer
This layer knows about the Domain and is responsible for mapping the Domain model to DTO and DTO to the Domain when we expose data. The DTO model usage should be encapsulated in the Data layer only.
@JsonClass(generateAdapter = true) | |
data class CategoryDTO( | |
@Json(name = "id") val id: Long, | |
@Json(name = "image") val image: String? = null, | |
@Json(name = "children") val children: List<CategoryDTO>, | |
@Json(name = "name") val name: String, | |
@Json(name = "ads_count") val adsCount: Int? = null, | |
@Json(name = "feed_type") val feedType: FeedType? = null, | |
) { | |
enum class FeedType(val id: String) : Serializable { | |
@Json(name = "map") | |
MAP("map"), | |
@Json(name = "default") | |
DEFAULT("default"); | |
companion object { | |
fun from(id: String): FeedType? { | |
return when (id) { | |
MAP.id -> MAP | |
DEFAULT.id -> DEFAULT | |
else -> null | |
} | |
} | |
} | |
} | |
} |
As you can see DTO models are used for parsing JSON and we shouldn’t expose this logic out of the Data layer, it allows us to align the DTO model with the server or local cache data structure.
Put mapping functions in the same Kotlin file as the model declaration.
fun CategoryDTO.asDomain() = Category( | |
id = id, | |
image = image, | |
name = name, | |
children = children.asDomain(), | |
adsCount = adsCount, | |
feedType = feedType.asDomain(), | |
) | |
fun Category.asDTO() = CategoryDTO( | |
id = id, | |
image = image, | |
name = name, | |
children = children.asDTO(), | |
adsCount = adsCount, | |
feedType = feedType.asDTO(), | |
) | |
fun List<Category>.asDTO(): List<CategoryDTO> { | |
return map { it.asDTO() } | |
} | |
fun List<CategoryDTO>.asDomain(): List<Category> { | |
return map { it.asDomain() } | |
} | |
fun CategoryDTO.FeedType?.asDomain() = when (this) { | |
CategoryDTO.FeedType.MAP -> Category.FeedType.MAP | |
CategoryDTO.FeedType.DEFAULT -> Category.FeedType.DEFAULT | |
else -> Category.FeedType.DEFAULT | |
} | |
fun Category.FeedType.asDTO() = when (this) { | |
Category.FeedType.MAP -> CategoryDTO.FeedType.MAP | |
Category.FeedType.DEFAULT -> CategoryDTO.FeedType.DEFAULT | |
} |
Domain layer
The Domain models shouldn’t contain any layer mapping function, because the Domain doesn’t know about Data and Presentation layers at all. The model on this layer shouldn’t be Parcalable or any other platform-dependent code. This layer is the source of truth in the project and should be platform-independent, so we can reuse it for different platforms (e.g. Kotlin multiplatform project).
Job Offers
data class Category( | |
val categoryId: Long, | |
val image: String?, | |
val children: List<Category>, | |
val name: String, | |
val adsCount: Int?, | |
val feedType: FeedType, | |
) { | |
val hasChildren: Boolean | |
get() = children.isNotEmpty() | |
val hasAds: Boolean | |
get() = adsCount != null && adsCount > 0 | |
enum class FeedType(val id: String) { | |
MAP("map"), | |
DEFAULT("default"); | |
} | |
} |
Presentation layer
The Presentation layer knows about the Domain but doesn’t know about the Data layer. The logic for mapping the Domain models into Presentation and vice versa should be in the Presentation layer. Models in this layer can be platform-dependent (e.g. Parcelable) and be more UI-specific.
@Parcelize | |
data class CategoryModel( | |
val categoryId: Long = ROOT_CATEGORY_ID, | |
val name: String = "", | |
val children: List<CategoryModel> = emptyList(), | |
val image: String? = null, | |
val adsCount: Int? = null, | |
val feedType: FeedType = FeedType.DEFAULT, | |
) : Parcelable { | |
companion object { | |
internal const val ROOT_CATEGORY_ID = 1L | |
} | |
@IgnoredOnParcel | |
val isRoot: Boolean | |
get() = categoryId == ROOT_CATEGORY_ID | |
@IgnoredOnParcel | |
val hasChildren: Boolean | |
get() = children.isNotEmpty() | |
@IgnoredOnParcel | |
val hasAds: Boolean | |
get() = adsCount != null && adsCount > 0 | |
enum class FeedType(val id: String) { | |
MAP("map"), | |
DEFAULT("default") | |
} | |
} |
Mapping functions:
fun Category.asPresentation() = CategoryModel( | |
categoryId = categoryId, | |
image = image, | |
name = name, | |
children = children.asPresentation(), | |
adsCount = adsCount, | |
feedType = feedType.asPresentation(), | |
) | |
fun Map<CategoryId, List<Category>>.asPresentation(): Map<CategoryIdModel, List<CategoryModel>> = | |
mapKeys { it.key.asPresentation() }.mapValues { it.value.asPresentation() } | |
fun List<Category>.asPresentation(): List<CategoryModel> = map { it.asPresentation() } | |
fun Category.FeedType.asPresentation() = when (this) { | |
Category.FeedType.MAP -> CategoryModel.FeedType.MAP | |
else -> CategoryModel.FeedType.DEFAULT | |
} | |
fun CategoryModel.asDomain() = Category( | |
categoryId = categoryId, | |
image = image, | |
name = name, | |
children = children.asDomain(), | |
adsCount = adsCount, | |
feedType = feedType.asDomain(), | |
) | |
fun Map<CategoryIdModel, List<CategoryModel>>.asDomain(): Map<CategoryId, List<Category>> = | |
mapKeys { it.key.asDomain() }.mapValues { it.value.asDomain() } | |
fun List<CategoryModel>.asDomain(): List<Category> = map { it.asDomain() } | |
fun CategoryModel.FeedType?.asDomain() = when (this) { | |
CategoryModel.FeedType.MAP -> Category.FeedType.MAP | |
else -> Category.FeedType.DEFAULT | |
} |
DTO to Domain Model mapping test
The mapping logic also needs to be coveted by JUnit tests
@Test | |
fun `On asDomain call should return domain model`() { | |
val categoryDTO = randomCategoryDTO() | |
val domainModel = categoryDTO.asDomain() | |
assertEquals(categoryDTO.id, domainModel.categoryId.id) | |
assertEquals(categoryDTO.image, domainModel.image) | |
assertEquals(categoryDTO.name, domainModel.name) | |
assertEquals(categoryDTO.children.size, domainModel.children.size) | |
assertEquals(categoryDTO.adsCount, domainModel.adsCount) | |
assertEquals(categoryDTO.feedType?.asDomain(), domainModel.feedType) | |
} |
Today we have covered a few changes that can add more structure to your project and improve code style.
Stay tuned for the next App Architecture topic to cover.
Thanks for reading.
This article is previously published on proandroiddev.com