Simply about architecture in Android
In today's world of development, choosing the right architecture is a difficult task. All developers strive to ensure that their code is clean, maintainable, and scalable.
In our humble world of Android development, there is a generally accepted approach to app design - Clean Architecture, which is recommended by Google. Despite the many articles dedicated to this standard, questions and debates around how to "properly cook" Clean Architec
Why Clean Architecture
When designing the architecture, you should pay attention to the type of your application, the amount of planned business logic in it, or, on the contrary, its absence. Important factors are the allotted time frame, the size of the team, and the prospect of integrating new members into it.
Whatever the specifics of your project, Clean Architecture is an excellent choice. Here's why:
Separation of responsibilities (scalability, minimization of dependency issues)
Testability
Resistance to change
Popularity (large number of guidelines, templates)
Fundamental principles
Let's look at the most basic principles of Clean Architecture, which is what makes it so:
Sole responsibility
Each layer (module), class or function performs only one task. This principle is intended to specify the responsibility of individual classes or layers, which in turn adds clarity to the code and reduces its coupling.
Separation into layers
The application should be divided into layers, each layer has its own area of responsibility. The following levels are usually distinguished: presentation, domain, data.
Presentation : Responsible for displaying the user interface and responding to its events
Domain : business logic, isolated from implementation details, defines the rules and operations for how the application should interact with data
Data : data storage
Dependency Inversion
One of the most important principles is to use a common contract, such as an interface or abstract class, instead of having the top layer directly depend on the components of the lower layer. This way, each layer uses this contract, which ensures that changes in the upper layer are isolated.
In Clean Architecture, the central layer is the domain , so we can schematically represent a similar relationship of dependencies:
We design
Let's start the practical part, in which we will consider each layer and its features using the example of a mini-application of a feed with "cats", using the Cat API .
We will use a multi-module approach to the layer structure in Clean Architecture, adding an optional common module.
PS: buildSrc will not be considered, it is a module for centralizing dependencies and defining tasks for gradle.
Since dependency inversion is an architectural principle, we will use a practical implementation ( Hilt ) to implement it.
Domain layer
Contains business logic, should be independent of the application implementation details and external libraries (exceptions can be made, example RxJava, DI Framework). This makes it a high-level layer.
Based on the description, we define two dependencies for the domain module: a dependency on Hilt and an optional common module.
plugins {
id("com.android.library")
id("org.jetbrains.kotlin.android")
id("com.google.dagger.hilt.android")
kotlin("kapt")
}
android {
namespace = "com.bsl.domain"
}
dependencies {
implementation(project(":common"))
implementation(libs.hilt.android)
kapt(libs.hilt.compiler)
}
domain:build.gradle.kts
Let's define the main components:
Entity : represents the data objects that the business logic operates on
Interactor or UseCase : contains functionality or operations
Repository : provides a contract for accessing data
Now, in accordance with our task, we will implement these components, define the business logic for obtaining a list of cats.
First, let's create an entity, define the CatModel class and declare the following fields in it: ID, image link and presence of a like.
package com.bsl.domain.cat.model
data class CatModel(
val id: String,
val imageUrl: String,
val isLiked: Boolean,
)
Using the Model postfix. We add it to our Entities. In principle, this is not necessary, and we can instead, for example, use CatEntity or just Cat.
Let's start creating a contract to get data. We'll define a CatRepository with a method to get all cats and like a specific cat.
package com.bsl.domain.cat.repository
import com.bsl.domain.cat.model.CatModel
interface CatRepository {
suspend fun getCats(limit: Int): List<CatModel>
suspend fun setLike(value: Boolean, id: String)
}
Typically in Clean Architecture, a single repository provides reading or writing of data within a particular entity and is named accordingly.
Now we are ready to describe the logic itself, it will be quite simple:
Extract data
Like and vice versa
But first, we need to figure out what to use, Interactor or UseCase?
UseCase : represents a specific operation
Interactor : is a set of operations on an entity
Essentially the difference is in how we want to separate our business operations.
At this point, all the work in the domain module is done and we will not return here. As a result, we have the following module structure:
Data module
Responsible for access to data (local and remote) and their management. The module is low-level, any dependencies are allowed.
Let's define the module dependencies, where the choice of libraries for data management is up to you:
plugins {
id("com.android.library")
kotlin("android")
kotlin("plugin.serialization")
id("com.google.dagger.hilt.android")
kotlin("kapt")
}
android {
namespace = "com.bsl.data"
}
dependencies {
implementation(project(":common"))
implementation(project(":domain"))
implementation(libs.hilt.android)
kapt(libs.hilt.compiler)
}
The basic components of this module are:
Repository Implementation : the implemented repository contracts that we defined in the domain module
Data Sources : specific implementations that provide access to data from various sources (database, API)
Data Models : data objects representing the stored information. Includes DTO (Data Transfer Objects), objects for CRUD operations (Create, Read, Update, Delete), as well as query objects (Query), etc.
Mappers : transforming data objects, for example into Entity
Let's start by defining the API data objects:
internal typealias CatResponseList = List<CatResponseModel>
@Serializable
internal data class CatResponseModel(
@SerialName("id") val id: String,
@SerialName("url") val url: String,
)
After that, it is worth making a mapper to transform the data object into a business logic entity. I will give an example using the extension approach, as it is quite concise in the context of the Kotlin language and simple.
internal fun CatResponseModel.mapToDomainModel(isLiked: Boolean) =
CatModel(id, url, isLiked)
internal fun CatResponseList.mapToDomainModels(
isLikedGetter: (String) -> Boolean,
) = map { it.mapToDomainModel(isLiked = isLikedGetter(it.id)) }
Since isLiked will be provided by an external component, we pass it through using parameters.
It is time to define our Data Sources, which will provide objects or their data particles. They can be divided into three types:
Remote : calls to external API to get data
Memory : interacting with data stored in RAM
Local : read data from local database
I will illustrate an example of using data sources of the Memory and Remote types . To store data in memory, I will use the HashMap structure, while the Ktor library is used to access the API.
internal class CatMemoryDataSource @Inject constructor(
private val referencesCache: ReferencesCache,
) {
fun setLike(value: Boolean, id: String) =
referencesCache.setValue(id, Reference.CatLike, value)
fun getLike(id: String, defaultValue: Boolean = false) =
referencesCache.getValue(id, Reference.CatLike) ?: defaultValue
}
internal class CatRemoteDataSource @Inject constructor(private val httpClient: HttpClient) {
suspend fun getCats(limit: Int) = httpClient.get {
url(GET_CATS_PATH)
parameter(LIMIT_PARAM, limit)
}.body<CatResponseList>()
companion object {
private const val ROOT = "https://api.thecatapi.com/v1/images"
private const val GET_CATS_PATH = "$ROOT/search"
private const val LIMIT_PARAM = "limit"
}
}
In practice, the code can be made cleaner, but for the sake of illustration, this approach was chosen.
Given a data entity and its sources, we can implement a repository.
internal class CatRepositoryImpl @Inject constructor(
private val catRemoteDataSource: CatRemoteDataSource,
private val catMemoryDataSource: CatMemoryDataSource,
) : CatRepository {
override suspend fun getCats(limit: Int): List<CatModel> =
catRemoteDataSource.getCats(limit)
.mapToDomainModels(isLikedGetter = { catMemoryDataSource.getLike(it) })
override suspend fun setLike(value: Boolean, id: String) =
catMemoryDataSource.setLike(value, id)
}
This completes our work here, this is what our module structure looks like:
Presentation
Responsible for rendering the user interface and managing its state based on business entities. The module is low-level, any dependencies are allowed.
Main components:
View : the user UI itself in the form of Fragment, Activity, Composable
Presenter / ViewModel : intermediary between UI and business logic in the model view MVVM, MVP, MVI
Let's define the module dependencies:
plugins {
id("com.android.library")
kotlin("android")
kotlin("plugin.serialization")
id("com.google.dagger.hilt.android")
kotlin("kapt")
id("com.google.devtools.ksp")
}
android {
namespace = "com.bsl.presentation"
buildFeatures {
compose = true
}
composeOptions {
kotlinCompilerExtensionVersion = compose.versions.compiler.get()
}
packagingOptions {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
}
dependencies {
implementation(project(":domain"))
implementation(project(":common"))
// ...
}
Let's start with the ViewModel implementation. I'll use the MVI presentation pattern, using its primitive implementation with a reference on MVI Orbit ( Github ).
Let's determine the screen state:
data class CatListState(val cats: ImmutableList<CatModel> = persistentListOf())
Using ImmutableList is justified by maintaining type stability for Compose. If you have XML markup, then I think you can get by with a regular List.
Now we define our ViewModel:
@HiltViewModel
class CatListViewModel @Inject constructor(private val catInteractor: CatInteractor) :
StatefulViewModel<CatListState, EmptySideEffect>(CatListState()) {
fun onLoad() = intent {
val cats = catInteractor.getRandomCats()
reduceState { state.copy(cats = cats.toImmutableList()) }
}
}
The View part will be independent according to your taste.
common module
The module is intended to distribute utilities to all other modules. If you want an extension/class to be available everywhere, then this is where it belongs.
It is a bad practice to add dependencies related to other layers (room, ktor, compose, view). Dependencies that can relate to all layers are allowed and should be distributed through api dependency
(logcat, coroutines, DI framework). I recommend following this rule, otherwise extensions will be available in the domain layer, for example, above View, which is a gross error.
In general, all utilities can be stored in the domain layer, depending on the preferences and needs of the project.
Sharing experience (Best Practices)
I have demonstrated a somewhat basic view of Clean Architecture, which, in my observations, is applied in most projects of different sizes. Now some tips on how we can make our tasks easier or, on the contrary, more difficult.
Interfaces for the sake of interfaces
Don't create interfaces unless there is a practical need for it, as this can complicate the code structure. For example, if a mapper is intended solely for a specific task and it is unlikely that it will have multiple implementations in the future, you should not create an interface for it. If there is a need for multiple implementations, I recommend creating an interface only for a specific case. In general, no one forbids you to omit interfaces for Interactor or UseCase.
Data
Data intended for display in the user interface should be supplied ready-made. To do this, mappers should be used to transform data from the data -> domain -> ui format.
For example, you shouldn't format a date in a certain way in the domain layer or directly in the View. It's better to do it in the ViewModel/Presenter in the context of the screen state or another POJO object.
Versatility
Avoid monotonously using the same pattern or approach throughout the application, as this can be harmful. For example, if it is easier to implement something using MVVM than MVI, then it is worth choosing the most convenient option.
Organizing the application functionality
It is always worth breaking the functionality of the application into features. This can be a separate screen, a dialog, or even logic without a UI component. It definitely happens that some DateUtils with dozens of date formatting methods can be considered a feature.
Documentation
Although it is said that good code does not require documentation, it is still advisable to provide brief descriptions to help understand sections of the code. Even if the code is written cleanly and efficiently, adding short comments can make it much easier to understand its purpose and functionality.
Conclusion
I have shown you how to implement Clean Architecture based on multi-module architecture, we have considered the area of responsibility and the main components of each layer. In this article on Habr I have tried to provide a simple and clear explanation of Clean Architecture. I hope that it will help you better understand this approach to application design.
Simplicity is more than the absence of complexity. Simplicity is the greatest weapon.