From Junior to Senior: the real way to implement Clean Architecture in Android

Morgane Soula
CodeX
Published in
11 min readMay 15, 2024

Introduction

I have been coding as an Android Developer for over 6 years now. Regardless of the technology, every developer’s journey starts with one thought I need to know how to code. And after your first Hello World and many years of practice, you slightly but surely shift your developer perspective from “I need to make it work” to “I need to know how and why it works”.

Like many developers, I experimented with various architectures in my personal and professional projects (MVC, MVP, and MVVM). However, I found it so challenging to test, scale, and maintain my code through time. Then, I discovered the Clean Architecture.

If you want to know more about Modularization, check out my second article of this serie!

I started my personal project HobbyMatchMaker and after talking with Guillaume, a Lead Senior Developer at Meero, he graciously pointed out a few mistakes I was making with my architecture. He generously shared his time and knowledge with me. And honestly, he is so easy to talk to: you feel smart whenever you chat with him. Thank you so much for your invaluable time and expertise!

I know many developers, like my past self, struggle with implementing Clean Architecture effectively. Allow me to illustrate, through two projects, how to adhere to Clean Architecture principles.

(source: Giphy)

Initialization

We will create a note-taking application with three main functionalities: displaying a list of notes, displaying its detail and adding a new note. Simple yet effective. First, I will demonstrate how many - myself — used to implement it, followed by a second version strictly using Clean Architecture principles. We will focus solely on Clean Architecture so don’t be surprised if I put little to no effort on the design.

For both projects I will be using the following stack: Kotlin, Jetpack Compose, Coroutines, Room, and Hilt. I am using Android Studio Iguana and Kotlin 1.9.22.

While I won’t be implementing tests, feel free to do it of course. It is a good exercice.

Remember, no code is perfect. I am perpetually becoming a better version of myself and I am enjoying every step of this journey. I am also more than happy to engage in discussions with any of you on the subject. Don’t forget: alone, we go faster, but together, we go further.

And now, let’s dive in.

(source: Giphy)

Packages — 1st Version — No (real) Clean Architecture

(Project view in Android Studio)

Let’s review this first project together, shall we?

Firstly, despite my efforts to create a multi-module project, there is only one module named app. All my folders are under the main package. One of the primary goals of the Clean Architecture is to only use what is needed in different layers. For example, if you need a DAO somewhere, you won’t probably also need the configuration of the database.

Setting aside the construction of my layers, let’s examine each one of them inside the features. We can see that in each feature, the domain layer seems to only contain UseCases. Ideally this layer should have any business logic related not only UseCases.

If we focus on only one of my features, let’s say main — also, the nomenclature is not ideal: “main” doesn’t convey any specific meaning for a feature — one thing should come to mind and totally breaks the principle of Clean Architecture: I only have one model NoteEntity. Through my entire project, I use this model which is the one representing my database entry. And most importantly, I am using this unique model across all layers. Each layer should have their own model. The model displayed to your users should differ from the one used in your database. Doing so I am breaking the Separation of concerns rule.

Additionally, the abstraction NoteDataSource and its concrete implementation NoteDataSourceImpl are both in the same layer, breaking the Dependency Inversion principle. And while we are at it, we should have two different packages for data sources: local and remote. While not mandatory if you only use local data, having separate packages would facilitate future additions of remote sources without disrupting the architecture. It would definitely be a good practice.

class GetAllNotesUseCase(private val noteDataSource: NoteDataSource) {
operate fun invoke(): Flow<List<NoteEntity>> = noteDataSource.getNotes()
}

Moving on to one of my UseCases. Back to one or two years ago, I did not understand most concepts behind the Clean Architecture. So it lead me to make some honest mistakes like the one above: not creating a repository. I was injecting the data source directly because I had only one source (a local one). But, it is always better to use a repository to handle different data sources (local and remote) to address the single source of truth principle.

But this case is perfect for illustrating the violation of the Liskov Substitution Principle. What does it mean? You can’t link a use case (or any class) to a specific implementation of another class. Keep in mind that your domain layer is completely independent and should never know about your data and presentation layers. It means you need to go through a contract — interface — and the data source should inherit from this interface. That way, it does not matter if you change the way the sources fetch your data: your use case won’t know about it since it is only connected to your interface. Therefore, your domain layer is independent of your data layer.

Moreover, we should point out that this use case returns a Flow. Doing so, it completely ignores any exceptions the repository could potentially encounter.

@HiltViewModel
class NoteViewModel @Inject constructor(
getNotesUseCase: GetAllNotesUseCase
) : ViewModel() {

val noteState: StateFlow<NotesFeedUiState> = getNotesUseCase()
.map { list ->
if (list.isEmpty()) {
NotesFeedUiState.Empty
} else {
NotesFeedUiState.Success(list)
}
}
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), NotesFeedUiState.Loading)
}

The presentation layer is only made of my ViewModel and my Composables. Honestly, it is not a bad ViewModel but it could be a little bit improved.

class NoteDataSourceImpl(private val noteDAO: NoteDAO) : NoteDataSource {

// Other CRUD methods

override suspend fun upsertNote(note: NoteEntity): Result<Boolean> =
try {
noteDAO.upsertNote(note)
Result.Success(true)
} catch (e: Exception) {
e.printStackTrace()
Result.Error(
throwable = e.cause ?: Throwable(message = "Could not upsert note")
)
}
}

Let’s focus on a single method from my CRUD concrete implementation. It returns a Result but is completely ignored in my use cases (and my missing Repository). Additionally, the exception handling could be improved. However, overall, it is a good method since it adheres to the Single Responsability Principle by only performing one task.

Let’s see how we can improve this project with a cleaner second version.

Packages — 2nd Version — Clean Architecture

First off? We now have 3 (real 🎉) main modules:

  • App
  • Core
  • Feature

The App module is mandatory. Here, you will find your MainActivity and everything related to the smooth running of your application (e.g., your Application file).

    // from build.gradle.kts (app)

// Modules
implementation(project(Modules.DATABASE))
implementation(project(Modules.DAO))
implementation(project(Modules.DI))
implementation(project(Modules.ADD_NOTE_PRESENTATION))
implementation(project(Modules.NOTE_DATA))
implementation(project(Modules.NOTE_DOMAIN))
implementation(project(Modules.NOTE_PRESENTATION))
implementation(project(Modules.NOTE_DETAIL_PRESENTATION))

Also, your app’s build.gradle should contain (almost) every module you will create in this project.

(Core module)

Inside Core you will place everything that is not a feature. We typically use it to group together code that provides basic functionality and utilities shared by other modules (e.g., database, design, common…). Each folder should be a module so that you can inject it whenever you need one of them.

(Feature module — — — build.gradle from feature.notes.domain)

And inside Feature you will place your… features (good catch!). Within each feature, you will create a module for each layer of the Clean Architecture: data, domain, and presentation. Your domain module will be injected in your presentation and data modules, but it will remain independent from them. However, keep in mind that you do not need to create a presentation layer (for example) if your feature won’t display anything.

(Domain module from feature.notes.domain)

Once again, let’s focus on the Notes module notice that the nomenclature is clearer this time and start with the domain layer. What did we learn earlier about the domain layer?

  • It knows nothing about data and presentation
  • It contains the business logic
  • The limit between domain and data is adjustable. Some people would prefer to put everything data sources related inside the Data folder instead: like the repository not being tailored to the domain layer.
// from feature.notes.domain.data_sources.local

interface NoteLocalDataSource {
fun observeNotes(): Flow<List<NoteDomainModel>>
suspend fun insertNote(note: NoteDomainModel): Result<Boolean>
suspend fun fetchNoteById(id: Long): Result<NoteDomainModel>
}

We have our first business logic folder: data sources. As previously mentioned, I have only included a local folder. As we want to respect the Liskov Principle, we have created an interface with the methods our business logic requires. When returning data, we encapsulate it into a custom Result object (from our common module). This sealed interface is made of two classes: Success and Failure. Implementing these methods is not the responsability of the domain layer, so we leave it at that.

    // from feature.notes.domain.repositories

suspend fun fetchNoteById(id: Long): Result<NoteDomainModel> {
return noteLocalDataSource.fetchNoteById(id)
}

Our Repository solely focus on working with the previous interface: it doesn’t matter where the data is coming from. We return a Result and most importantly, we return a custom model NoteDomainModel tailored to this layer. Every file requiring a model within the domain layer utilizes this custom model.

// from feature.notes.domain.use_cases

class InsertNoteUseCase(
private val noteRepository: NoteRepository
) {
suspend operator fun invoke(note: NoteDomainModel): Result<Boolean> {
return noteRepository.insertNote(note)
}
}

And finally, we have some use cases. As mentioned earlier, it uses the custom model NoteDomainModel and returns a Result so we can handle exception in our presentation layer.

(from feature.notes.data)

Let’s proceed to our data module layer. Its purpose is to fetch data: it can be provided by your storage device, database, server, API, shared preferences… In our case, we focus on a straightforward example utilizing a local database. For those interested in exploring API functionality, I recommend checking out my HobbyMatchMaker project.

Our key file here is NoteLocalDataSourceImpl, the concrete implementation of the NoteLocalDataSource interface defined in our domain layer.

// from features.notes.data.data_sources.local

class NoteLocalDataSourceImpl(
private val noteDAO: NoteDAO
) : NoteLocalDataSource {

// Other methods

override fun observeNotes(): Flow<List<NoteDomainModel>> {
return noteDAO.observeNotes()
.map { list ->
list.map { noteEntityModel -> noteEntityModel.toNoteDomainModel() }
}
.onEmpty {
return@onEmpty
}
}
}

NoteDAO is injected through the class constructor. NoteDAO is defined in another module named dao within the parent module database encapsulated in the main module core. As mentioned before, when requiring a DAO, only the interface is necessary, not the database implementation.

// from core.database.main

@Module
@InstallIn(SingletonComponent::class)
object DatabaseModule {

@Provides
@Singleton
fun provideDatabase(
@ApplicationContext context: Context
): NoteAppCleanDatabase =
Room.databaseBuilder(
context,
NoteAppCleanDatabase::class.java,
"database-clean"
)
.build()

@Provides
fun providesNoteDAO(database: NoteAppCleanDatabase): NoteDAO = database.noteDAO()
}

We retrieve entities from the database NoteEntityModel and since our domain layer exclusively interacts with NoteDomainModel, we employ a mapper.

// from feature.notes.data.data_sources.local.mappers

fun NoteEntityModel.toNoteDomainModel(): NoteDomainModel {
return NoteDomainModel(
id = this.id,
title = this.title,
description = this.description
)
}

It’s the responsibility of the data layer to adapt to the requirements of the domain layer. Never the other way around.

(from feature.notes)

Lastly, our last layer: presentation. Its primary function is to present data to our users or any interaction with them, handling all aspect related to UI or UX. To do so, we will need a specific model NoteUiModel. Good practice here is to annotate it with Immutable.

We will find 3 main elements: our Mapper, Composables, and ViewModel. The mapper follows the same pattern as seen in our data layer: it transforms our business model into a UI model.

// from feature.notes.presentation

@Composable
fun NoteScreen(
modifier: Modifier = Modifier,
notes: List<NoteUiModel>,
openNoteDetail: (id: Long) -> Unit
) { // content }

Composables represent different part of our screens, such as the list of notes in our case. We utilize our UI model NoteUiModel and employ lambda functions whenever interaction with our ViewModel is necessary.

// from feature.notes.presentation

@OptIn(ExperimentalCoroutinesApi::class)
@HiltViewModel
class NoteViewModel @Inject constructor(
private val observeNotesUseCase: ObserveNotesUseCase
) : ViewModel() {

val noteState: StateFlow<NoteUiStateModel> by lazy {
observeNotesUseCase()
.mapLatest { notes ->
if (notes.isEmpty()) {
NoteUiStateModel.Empty
} else {
NoteUiStateModel.Fetched(notes.map { it.toNoteUiModel() }.toPersistentList())
}
}
.flowOn(Dispatchers.Main)
.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(5000),
NoteUiStateModel.Loading
)
}
}

And finally, our ViewModel. We inject our use case because, for this feature, we only need to observe our list of notes and to display it.

Room being reactive, any addition to the database triggers the observable variable noteState (because it is a Flow). Depending on the data received, we adjust the UI with the appropriate model. Here, we use NoteUiStateModel which is a sealed interface comprising 3 possibilities: Loading (default state), Empty, and Fetched.

To enhance app performance, you can set your list as being persistent. This prompts the Compose compiler to mark the list as stable.

Conclusion

We made it to the end! Congratulations! I hope you gain some new insights and that I have aroused your curiosity.

However, for the sake of simplicity in this article, there are some topics that I haven’t covered, such as (non-exhaustive list):

  • The use of SavedStateHandle in our ViewModel (you should definitely explore it, documentation is available here)
  • The implementation of Hilt across our projects (although you may have noticed our DI module in almost every module). On the other hand, if you want to explore KMP, Koin is definitely the best dependency injection tool!
  • The Navigation pattern. I highly recommend checking out Francesc Vilariño’s article on this topic. It is the approach I currently use in all my projects. And recently, the Android team cooked us something really great: Ian Lake made this article about Compose Navigation being Type Safe 🎊!

You can find both of these projects on my Github page: NoteApp and NoteAppClean.

Again, I explore modularization in this second article. Check it out 😊

Let’s connect on LinkedIn if you want to see more!

Stay tuned for another episode of the Android ecosystem 👀!

Keep coding, keep exploring new topics and, most importantly, keep enjoying it!

--

--