A guide to Clean Architecture in Android

Hi folks, hope you are doing great. In this blog, we will discuss and also implement Uncle Bob's Clean Architecture of software development in Android. Just stay tuned

Introduction

The actual question is "What is the need for clean architecture?". Well, the main goal of Clean Architecture is to create software that is easy to understand, test, maintain, and modify over time, while also being flexible and scalable for that purpose we differentiate our application into three layers.

  1. Presentation layer

  2. Domain layer

  3. Data layer

  • The presentation layer contains our UI logic like user input or displaying any data to the user

  • The domain layer contains the business logic of the application. this layer contains the use cases, models and repositories.

  • The data layer deals with data whether it is stored locally or we are retrieving it from APIs

Bored with theory? let's understand it with the help of a project example

Structure of project

This is a Note taking app in which we will use Jetpack compose, Room and dagger hilt. So add their dependencies given below in ur app level build.gradle file

// Di using hilt
    implementation "com.google.dagger:hilt-android:2.44"
    kapt "com.google.dagger:hilt-compiler:2.44"
    //viewModel
    implementation"androidx.lifecycle:lifecycle-viewmodel-compose:$compose_ui_version"
    //navigation
    implementation "androidx.navigation:navigation-compose:$nav_version"
    //room-database
    implementation "androidx.room:room-runtime:$room_version"
    implementation "androidx.room:room-ktx:$room_version"
    kapt "androidx.room:room-compiler:$room_version"

After that create five packages named-

-presentation

-domain

-data

-di

-utils

Setting up the domain layer

The domain layer will contain packages

  1. model

  2. repository

the model package will contain a Note.kt file which contains entities

package com.android.roomdbtest.domain.model

import androidx.room.Entity
import androidx.room.PrimaryKey

@Entity
data class Note(
    @PrimaryKey val id: Int? = null,
    val title: String,
    val content: String,
    )

the same class will be used as our room database entities. Next, we will write our repository. The repository provides a consistent interface for accessing and manipulating the data.

package com.android.roomdbtest.domain.repository

import com.android.roomdbtest.domain.model.Note
import kotlinx.coroutines.flow.Flow

interface NoteRepository {

    fun getNotes(): Flow<List<Note>>

    suspend fun getNoteById(noteId: Int): Note?

    suspend fun insertNote(note: Note)

    suspend fun deleteNote(note: Note)
}

our domain layer is now completed let's set up our data layer

Setting up the data layer

As we are using the room database to store data locally. so this package will contain a local database and repository package. room uses dao 's as data accessing objects.

package com.android.roomdbtest.data.local

import androidx.room.*
import com.android.roomdbtest.domain.model.Note
import kotlinx.coroutines.flow.Flow

@Dao
interface NoteDao{
    @Query ("Select * FROM note")
    fun getNotes(): Flow<List<Note>>

    @Query("Select * FROM note WHERE id = :id")
    suspend fun getNoteById(id:Int):Note?

    @Insert(onConflict = OnConflictStrategy.REPLACE)
    suspend fun insertnote(note: Note)

    @Delete
    suspend fun deleteNote(note:Note)
}

after that set up the database class

package com.android.roomdbtest.data.local

import androidx.room.Database
import androidx.room.RoomDatabase

import com.android.roomdbtest.domain.model.Note

@Database(
    entities = [Note::class],
    version = 1
)
abstract class NoteDatabase : RoomDatabase() {

    abstract fun noteDao() :NoteDao

    companion object{
        const val DATABASE_NAME = "notes_db"
    }
}

we are finished setting up the room database. now let's setup the implementation of domain layer repositories

package com.android.roomdbtest.data.repository

import com.android.roomdbtest.data.local.NoteDao
import com.android.roomdbtest.domain.model.Note
import com.android.roomdbtest.domain.repository.NoteRepository
import kotlinx.coroutines.flow.Flow

class NoteRepositoryImpl
   (private val noteDao: NoteDao
    ) : NoteRepository {

        override fun getNotes(): Flow<List<Note>> {
            return noteDao.getNotes()
        }

        override suspend fun getNoteById(noteId: Int): Note? {
            return noteDao.getNoteById(noteId)
        }

        override suspend fun insertNote(note: Note) {
            return noteDao.insertnote(note)
        }

        override suspend fun deleteNote(note: Note) {
            return noteDao.deleteNote(note)
        }
  }

this class extends our domain layer repository class and overrides its function and it maps the repository to the room database

Setting up dependency injection

Dependency injection is a technique for providing dependencies to classes, rather than having them create the dependencies themselves. In our di package, there is a file AppModule.kt which provides dependencies to the app the whole time

package com.android.roomdbtest.di

import android.app.Application
import com.android.roomdbtest.data.local.NoteDatabase
import com.android.roomdbtest.domain.repository.NoteRepository
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
import androidx.room.Room
import com.android.roomdbtest.data.repository.NoteRepositoryImpl


@Module
@InstallIn(SingletonComponent::class)
object AppModule {
    @Singleton
    @Provides
    fun provideNoteDatabase(app: Application) = Room.databaseBuilder(
        app, NoteDatabase::class.java, NoteDatabase.DATABASE_NAME
    ).build()

    @Singleton
    @Provides
    fun provideNoteRepository(database: NoteDatabase): NoteRepository = NoteRepositoryImpl(
        database.noteDao()
    )
}

Setting up the presentation layer

In the presentation layer, we will use Jetpack Compose for UI design and handling. The App will contain two screens one for displaying all the notes and the other for the insertion of notes. The navigation library will be used to switch between two screens and viewmodel class to retrieve data from the repository.

ViewModel

package com.android.roomdbtest.presentation.update_note

import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.android.roomdbtest.domain.model.Note
import com.android.roomdbtest.domain.repository.NoteRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch
import javax.inject.Inject

@HiltViewModel
class NoteViewModel @Inject constructor(private val repository: NoteRepository): ViewModel() {
    private val _noteList = MutableStateFlow<List<Note>>(emptyList())
    val noteList = _noteList.asStateFlow()

    init {
        viewModelScope.launch(Dispatchers.IO) {
            repository.getNotes().distinctUntilChanged().collect { listOfNotes ->
                if (listOfNotes.isEmpty()) {
                    _noteList.value = emptyList()
                    Log.d("Empty", ": Empty list")
                } else {
                    _noteList.value = listOfNotes
                }
            }
        }
    }

    fun addNote(note: Note) = viewModelScope.launch { repository.insertNote(note) }

    fun deleteNote(note: Note) = viewModelScope.launch { repository.deleteNote(note) }
    fun getAllNotes() = viewModelScope.launch { repository.getNotes() }
}

HomeScreen(list screen)

package com.android.roomdbtest.presentation.home_screen

import android.annotation.SuppressLint
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Delete
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import com.android.roomdbtest.domain.model.Note
import com.android.roomdbtest.presentation.navigation.Screens
import com.android.roomdbtest.presentation.update_note.NoteViewModel

@Composable
fun HomeScreen(noteViewModel: NoteViewModel,navController: NavController) {
    val notes = noteViewModel.noteList.collectAsState().value
    NoteListScreen(notes = notes, noteViewModel =noteViewModel, navController = navController )
}

@OptIn(ExperimentalMaterialApi::class)
@SuppressLint("UnusedMaterialScaffoldPaddingParameter")
@Composable
fun NoteListScreen(
    notes: List<Note>,
    noteViewModel: NoteViewModel,
    navController: NavController
) {
    Scaffold(
        topBar = {
            TopAppBar(
                title = { Text(text = "Notes") }
            )
        },
        content = {
            Column(
                modifier = Modifier
                    .padding(16.dp)
                    .fillMaxSize()
            ) {
                LazyColumn(
                    modifier = Modifier.weight(1f)
                ) {
                    items(notes) { note ->
                        Card(
                            modifier = Modifier
                                .padding(8.dp)
                                .fillMaxWidth(),
                            elevation = 8.dp,
                            shape = RoundedCornerShape(16.dp),
                            onClick = {  }
                        ) {
                            Column(
                                modifier = Modifier.padding(16.dp)
                            ) {
                                Text(text = note.title, style = MaterialTheme.typography.h6)
                                Spacer(modifier = Modifier.height(8.dp))
                                Text(text = note.content, style = MaterialTheme.typography.body2)
                                Spacer(modifier = Modifier.height(8.dp))
                                Row(
                                    modifier = Modifier.fillMaxWidth(),
                                    horizontalArrangement = Arrangement.End
                                ) {
                                    IconButton(
                                        onClick = { noteViewModel.deleteNote(note = note) }
                                    ) {
                                        Icon(Icons.Filled.Delete, contentDescription = "Delete")
                                    }
                                }
                            }
                        }
                    }
                }
                FloatingActionButton(
                    onClick = { navController.navigate(Screens.UpdateNoteScreen.route)},
                    modifier = Modifier.align(Alignment.End),
                    content = {
                        Icon(Icons.Filled.Add, contentDescription = "Add")
                    }
                )
            }
        }
    )
}

Add note

package com.android.roomdbtest.presentation.update_note

import android.annotation.SuppressLint
import android.widget.Toast
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import com.android.roomdbtest.domain.model.Note
import com.android.roomdbtest.presentation.navigation.Screens



@SuppressLint("UnusedMaterialScaffoldPaddingParameter")

@Composable
fun UpdateNoteScreen(
    noteViewModel: NoteViewModel,
    navController: NavController
)
{
    var title by remember { mutableStateOf("") }
    var content by remember { mutableStateOf("") }
    val context = LocalContext.current

    Scaffold(
        topBar = {
            TopAppBar(
                title = { Text(text = "Add Note") },
                navigationIcon = {
                    IconButton(onClick = { navController.navigate(Screens.HomeScreen.route) }) {
                        Icon(Icons.Filled.ArrowBack, contentDescription = "Back")
                    }
                }
            )
        },
        content = {
            Column(
                modifier = Modifier
                    .padding(16.dp)
                    .fillMaxWidth()
            ) {
                OutlinedTextField(
                    value = title,
                    onValueChange = { title = it },
                    label = { Text(text = "Note Title") }
                )
                Spacer(modifier = Modifier.height(16.dp))
                OutlinedTextField(
                    value = content,
                    onValueChange = { content = it },
                    label = { Text(text = "Note Content") }
                )
                Spacer(modifier = Modifier.weight(1f))
                Button(
                    onClick = {
                        if (title.isNotEmpty() && content.isNotEmpty()) {
                            noteViewModel.addNote(note = Note(title = title, content = content))
                            Toast.makeText(context,"Note Added",Toast.LENGTH_SHORT).show()
                        }
                        else{

                            Toast.makeText(context,"Please enter note",Toast.LENGTH_SHORT).show()
                        }
                       navController.navigate(Screens.HomeScreen.route)
                              },
                    modifier = Modifier.fillMaxWidth(),
                    contentPadding = PaddingValues(vertical = 12.dp)
                ) {
                    Text(text = "Save")
                }
            }
        }
    )
}

now set up the navigation graph

Screens

package com.android.roomdbtest.presentation.navigation

sealed class Screens(val route: String) {
    object HomeScreen : Screens("note_list_screen")
    object UpdateNoteScreen : Screens("note_create_update_screen")
}

NavGraph

package com.android.roomdbtest.presentation.navigation

import androidx.compose.runtime.Composable
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import com.android.roomdbtest.presentation.home_screen.HomeScreen
import com.android.roomdbtest.presentation.update_note.NoteViewModel
import com.android.roomdbtest.presentation.update_note.UpdateNoteScreen


@Composable
fun NavGraph(navController: NavHostController = rememberNavController(),
             noteViewModel: NoteViewModel
)
{
NavHost(navController = navController, startDestination = Screens.HomeScreen.route ){
   composable(route = Screens.HomeScreen.route){
       HomeScreen( navController =navController, noteViewModel = noteViewModel )
   }
   composable(route = Screens.UpdateNoteScreen.route){
      UpdateNoteScreen(noteViewModel = noteViewModel, navController =navController )
   }
}
}

Finishing up the project

Don't forget to annotate your main activity with @AndroidEntryPoint annotation.

create a class named NoteApplication and extend it with Application() and don't forget to add .NoteApplication in your Android manifest

package com.android.roomdbtest

import android.app.Application
import dagger.hilt.android.HiltAndroidApp

@HiltAndroidApp
class NoteApplication : Application()

Final Result

You can refer to my github repository for source code of this app