Architecting for Flexibility: Unveiling the Layers of Clean Architecture - Part 2
Hello Engineers,
This is the second part of the Modern Android App Development with Clean Architecture Series where we are going to learn about the different layers of clean architecture, and how they are interconnected to build a robust and modular Android application built for scalability.
I would recommend going through part one of this series first if you haven't, where we have delved into the solid principles that will form the foundation and fundamentals of this series.
What is clean architecture?
Imagine a scenario where we are tasked with adding a simple feature to an existing Android application, We take a look at the existing code structure and implementation and notice that even though it’s a simple feature that we would have probably implemented in a few hours, we discover that it will take longer than anticipated because the dependency is tightly coupled and so we’ll have to work on the architecture before adding any features or else everything will come crumbling down.
Another scenario is we are tasked with developing an application but the decision on whether to use Realm or Room has not yet been finalized, and so we decide to work on other parts of the application while awaiting clarifications. The decision on where to start focusing on can only be made upon a comprehensive understanding of Android architecture, ensuring smooth integration of the database when the time is right.
For such scenarios, we are going to need something less coupled where every concern is properly separated and independence of components and modules, which properly defines a proper mechanism for layer-to-layer communication, and a good architecture should have this in mind for independence of details like database interactions, inputs, outputs or networking logic.
Clean architecture to the rescue
Clean architecture is also known as onion architecture, we’ll see why in a bit. This architecture was introduced to us by Robert. C . Martin, also known as Uncle Bob a software engineer, instructor, and author. The goal of clean architecture is to manage complexities in our applications by reducing dependencies and as a solution, we divide an application into distinct modules or layers, each with assigned responsibilities, which leads to a lowly coupled and modular architecture that forms a good foundation for a scalable application.
Layers of clean architecture
Layers serve as core components of clean architecture and the layers should not know about each other and only use interfaces to communicate. There are opinions about how many layers an application should have, but the main idea should be guided by the project requirements and needs. There is no one-size-fits-all solution regarding the number of layers to use.
These are the main layers
Presentation layer
Domain layer
Data/Model
Clean architecture (onion) layers representation
The above illustration resembles an onion’s inner part and this is why clean architecture is also referred to as onion architecture and the rule of the onion is the inner layer should never depend on the outer layer and the dependency is only unidirectional, the outer layers depend on the inner layers. Let’s look at the layers in more detail.
Presentation layer
This layer integrates with the user interface while interacting with the ViewModel. In this layer, we can apply the MVVM (Model View ViewModel) architecture and the relationship between the view, ViewModel, and the model is unidirectional where the view has a dependency on the ViewModel which has a dependency on the model but not directly and this is where the domain layer will come in with usecases and the repository.
Illustrating unidirectional dependency
The views should only handle UI-related presentations and user inputs not hold any business logic, and only talk directly to a ViewModel that communicates with the use cases in the domain layer. Here is an example of a ViewModel.
@HiltViewModel
class MovieReviewsViewModel @Inject constructor(private val movieReviewUseCase: MovieReviewUseCase) :
ViewModel() {
private val _movieReviews = MutableStateFlow(MovieReviewsUiStates())
val movieReviews = _movieReviews.asStateFlow()
fun getReviews(id: Int){
try {
viewModelScope.launch {
_movieReviews.value = MovieReviewsUiStates(isLoading = true)
val response = movieReviewUseCase(id).data?.review?.asFlow()
_movieReviews.value = MovieReviewsUiStates(reviews = response!!)
}
}catch (e:Exception){
_movieReviews.value = MovieReviewsUiStates(error = handleMovieCastsErrors(e))
}
}
private fun handleMovieCastsErrors(e:Exception):String{
return when (e) {
is IOException -> "An unexpected error occurred: Please check Network/Internet settings"
is HttpException -> "Unexpected network error occurred. Check API connection"
else -> "An unexpected error occurred"
}
}
}
We created a getReviews(id:Int)
method which takes an id as a parameter and we launch a coroutine in theviewModelScope
to get the movie reviews by interacting with the usecase class in the domain layer using the line of code at val response = movieReviewUseCase(id).data?.review?.asFlow()
The use case is located in the domain module, which is supposed to handle all the logic for the application.
Let’s have a look at the domain layer and assess how all this is achieved.
Domain layer
The domain layer is the innermost and the core layer of clean architecture and cannot easily be changed, unlike the outer layers which are prone to changes. It contains a repository interface, entities, and usecases that reflect the features of our application, it is simply the business logic of our application.
We will hear developers referring to clean architecture as the screaming architecture because when we look at the file structure before looking at the code we can easily tell what the application is all about and what everything does, it simply screams its intents thanks to the usecases which are also known as interactors since they are the business logic and executors of the different functionalities of an application.
Here is an example of a usecase.
class MovieReviewUseCase @Inject constructor(private val repository: MovieRepository) {
suspend operator fun invoke(id: Int) = repository.getMovieReviews(id)
}
We can easily tell what the MovieReviewUseCase
does by the name and most importantly, using its code which only has one responsibility assigned to it. It fetches movie reviews from the API from the repository interface which acts as an abstraction for its implementations, as we have demonstrated in the above code.
Above, we have the full view of our usecases, which clearly illustrates what every single usecase does in the application.
Data layer
The data layer exposes the data sources to outside dependencies by providing abstract definitions, for example, repository implementation, hence acting as an intermediary between the domain layer and other components in the application for example local storage and remote data sources and the main idea behind it is to abstract the data sources, and this mainly done by the use of interfaces.
The data layer is responsible for handling the following:
Data caching mechanisms for improved performance by reducing network requests to the server.
Data sources are abstracted by using the repository pattern to only expose the interfaces to the outside layers and hide their implementation.
Handle all errors related to fetching data locally or remotely.
Here is an example of a repository concrete class.
class NewsRepositoryImpl(val db: ArticleDatabase) : NewsRepository {
override suspend fun searchNews(searchQuery: String, pageNumber: Int) =
RetrofitInstance.api.searchNews(searchQuery, pageNumber)
override suspend fun insertFavouriteArticles(favouriteArticles: FavouriteArticles) =
db.getFavouriteDao().insertFavouriteArticles(favouriteArticles)
override fun getFavouriteNews() = db.getFavouriteDao().getFavouriteArticles()
}
Repository interface implementation
The above shows a repository
implementation that cannot be accessed directly by the usecases instead we use the repositoryInterface
where we have searchNews()
, insertFavouriteArticles()
and getFavouriteNews()
which are supposed to interact with the local database to save and retrieve data from the Room database.
interface NewsRepository {
suspend fun searchNews(searchQuery: String, pageNumber: Int): Response<NewsResponse>
suspend fun insertFavouriteArticles(favouriteArticles: FavouriteArticles)
fun getFavouriteNews(): Flow<List<FavouriteArticles>>
}
We use this interface to access the concrete implementation, acting as an abstraction to classes that need the repository for example, the usecases in the domain layer or the ViewModel.
The repository interface acts as a gateway to the data layer and so this is the only class we are going to expose to the other layers.
In the next part, we are going to look at dependency injection and what role it plays in clean architecture in improving robustness while maintaining flexibility and modularity.