Kamrul Hasan
8 min readMar 16, 2023

Developing a Multi-Module Android App with Compose, Clean Architecture, MVVM, Hilt, Retrofit, and More.[PART-ONE]

Android Compose is a new UI toolkit that Google has introduced. It allows developers to build beautiful and responsive user interfaces in a declarative way. In this blog post, we’ll take a look at how to use Android Compose in a multi-module app that follows Clean Architecture principles, uses MVVM design patterns, and incorporates Hilt for dependency injection and Retrofit for network calls.

What is Multi-Module architecture?

In a multi-module architecture, each module represents a specific feature or set of features in the app. Each module has its own set of responsibilities and is built as a separate component. This architecture promotes modularity, which makes the codebase easier to maintain and update.

What is Clean Architecture?

Clean Architecture is a software design paradigm that aims to make code more modular, testable, and scalable. The basic idea behind Clean Architecture is to separate the concerns of different parts of the codebase so that changes in one area don’t affect other areas.

What is MVVM Design Pattern?

MVVM stands for Model-View-ViewModel. It is a design pattern that separates the concerns of the user interface (View) from the business logic (ViewModel) and the data (Model). This pattern promotes testability and maintainability.

With these concepts in mind, let’s take a look at how we can build an Android app that incorporates all of them.
Source Code: Github-Link

Create a multi-module project: Create a multi-module project with the following modules:

  • app: contains the code for the application and combines all the module
  • features: contains the code for the user interface
  • domain: contains the business logic and use cases
  • data: contains the data access code
  • di: contains all the dependency code
  • model: contain data model class
  • buildSrc: directory at the Gradle project root, which can contain our build logic.
multi-module layer

In this chapter we will implement the buildSrc module.

What is buildSrc?
In the Android build system, buildSrc is a Gradle module that allows you to write build logic in your project using Kotlin or Groovy scripts instead of XML configuration files.The buildSrc module is automatically compiled and included in your project's build process. You can use it to define custom tasks, plugins, and dependencies for your project. This can be especially useful for complex build scenarios where you need to customize the build process beyond what is possible with the standard Android Gradle plugins.

Enough talk, Let’s go deep dive for code. Create a directory and name it buildSrc. Inside it create src -> main -> java directory

Now Create a gradle.properties file and write the code.

kotlinVersion = 1.8.10
systemProp.hiltVersion = 2.44.2
androidGradlePluginVersion = 7.4.2

Next, we need to create build.gradle.kts file and write the code.

import org.gradle.kotlin.dsl.`kotlin-dsl`
plugins {
`kotlin-dsl`
}

buildscript {
repositories {
mavenLocal()
mavenCentral()
google()
maven ("https://jitpack.io")
maven ("https://oss.jfrog.org/libs-snapshot")
}
extra.apply {
set("kotlinVersion",project.properties["kotlinVersion"])
set("hiltVersion",System.getProperty("hiltVersion"))
set("androidGradlePluginVersion",project.properties["androidGradlePluginVersion"])
}
}

repositories {
mavenLocal()
mavenCentral()
google()
maven ("https://jitpack.io")
maven ("https://oss.jfrog.org/libs-snapshot")
}


dependencies {
implementation("com.android.tools.build:gradle:${project.properties["androidGradlePluginVersion"]}")
implementation("org.jetbrains.kotlin:kotlin-gradle-plugin:${project.properties["kotlinVersion"]}")
implementation("com.google.dagger:hilt-android-gradle-plugin:${System.getProperty("hiltVersion")}")
}

Next, create the core package and crate Dependencies, ModulesDep, Versions, and AppConfig classes inside the package.

//Versions.kt
internal object Versions {
const val composeVersion = "1.3.3"
const val composeNavigationVersion = "2.5.3"
const val coreKtxVersion = "1.8.0"
const val materialVersion = "1.3.1"
const val activityComposeVersion = "1.6.1"
const val lifecycleVersion = "2.5.1"
const val runtimeComposeVersion = "2.6.0-beta01"
const val retrofitVersion = "2.9.0"
val hiltVersion:String = System.getProperty("hiltVersion")
const val hiltNavigationComposeVersion = "1.0.0"
const val timberVersion = "5.0.1"
const val okhttp3Version = "4.10.0"
const val gsonVersion = "2.9.1"
const val coilVersion = "2.2.2"
const val coroutinesVersion = "1.3.9"
const val leakcanaryVersion = "2.9.1"
const val espressoCoreVersion = "3.5.0"
const val jUnitVersion = "4.13.2"
const val jUnitExtVersion = "1.1.4"

}
//Dependencies.kt
internal object Dependencies {
const val coreKtx = "androidx.core:core-ktx:${Versions.coreKtxVersion}"
const val composeMaterial = "androidx.compose.material:material:${Versions.materialVersion}"
const val composeActivity = "androidx.activity:activity-compose:${Versions.activityComposeVersion}"
const val composeUi = "androidx.compose.ui:ui:${Versions.composeVersion}"
const val composeNavigation = "androidx.navigation:navigation-compose:${Versions.composeNavigationVersion}"
const val composePreviewUi = "androidx.compose.ui:ui-tooling-preview:${Versions.composeVersion}"

const val viewModel = "androidx.lifecycle:lifecycle-viewmodel-compose:${Versions.lifecycleVersion}"
const val viewModelSaveState = "androidx.lifecycle:lifecycle-viewmodel-savedstate:${Versions.lifecycleVersion}"
const val liveData = "androidx.lifecycle:lifecycle-livedata-ktx:${Versions.lifecycleVersion}"
const val runtimeCompose = "androidx.lifecycle:lifecycle-runtime-compose:${Versions.runtimeComposeVersion}"
const val lifecycleService = "androidx.lifecycle:lifecycle-service:${Versions.lifecycleVersion}"

val hiltAndroid = "com.google.dagger:hilt-android:${Versions.hiltVersion}"
const val hiltNavCompose = "androidx.hilt:hilt-navigation-compose:${Versions.hiltNavigationComposeVersion}"
val hiltCompiler = "com.google.dagger:hilt-android-compiler:${Versions.hiltVersion}"

const val retrofit = "com.squareup.retrofit2:retrofit:${Versions.retrofitVersion}"
const val rxJava3adapter = "com.squareup.retrofit2:adapter-rxjava3:${Versions.retrofitVersion}"
const val retrofitGsonConverter = "com.squareup.retrofit2:converter-gson:${Versions.retrofitVersion}"
const val okhHttp3 = "com.squareup.okhttp3:okhttp:${Versions.okhttp3Version}"
const val okhHttp3Interceptor = "com.squareup.okhttp3:logging-interceptor:${Versions.okhttp3Version}"
const val gson = "com.google.code.gson:gson:${Versions.gsonVersion}"

const val kotlinCoroutines = "org.jetbrains.kotlinx:kotlinx-coroutines-android:${Versions.coroutinesVersion}"
const val leakcanary = "com.squareup.leakcanary:leakcanary-android:${Versions.leakcanaryVersion}"
const val timber = "com.jakewharton.timber:timber:${Versions.timberVersion}"
const val coil = "io.coil-kt:coil-compose:${Versions.coilVersion}"


const val jUnit = "androidx.compose.ui:ui-test-junit4:${Versions.composeVersion}"
const val jUnitExt = "androidx.test.ext:junit:${Versions.jUnitExtVersion}"
const val jUnitTestUi = "androidx.compose.ui:ui-test-junit4:${Versions.composeVersion}"
const val espresso = "androidx.test.espresso:espresso-core:${Versions.espressoCoreVersion}"
const val composeTooling = "androidx.compose.ui:ui-tooling:${Versions.composeVersion}"
const val composeTestManifest = "androidx.compose.ui:ui-test-manifest:${Versions.composeVersion}"

}
//ModulesDep.kt
object ModulesDep {
const val di = ":di"
const val data = ":data"
const val domain = ":domain"
const val apiResponse = ":model:apiresponse"
const val entity = ":model:entity"
const val common = ":common"

const val repoList = ":features:repolist"
const val profile = ":features:profile"
}

//AppConfig.kt
object AppConfig {
const val applicationId = "com.iamkamrul.compose"
const val testRunner = "androidx.test.runner.AndroidJUnitRunner"
const val minSdkVersion = 21
const val compileSdkVersion = 33
const val targetSdkVersion = 33
const val kotlinCompilerExtensionVersion = "1.4.2"
const val versionCode = 1
const val versionName = "1.0.0"
}

Next, need to create the Dependencies group and extension functions. So that all the modules can use it. Now create the dependencies package. Inside the package create the necessary files and implement the code.

//GroupedDepHandlerExtension.kt
package dependencies
import core.Dependencies

internal val androidComposeDependencies = listOf(
Dependencies.coreKtx,
Dependencies.composeMaterial,
Dependencies.composeActivity,
Dependencies.composeUi,
Dependencies.composePreviewUi,
Dependencies.composeNavigation
)

internal val androidxLifeCycleDependencies = listOf(
Dependencies.viewModel,
Dependencies.liveData,
Dependencies.runtimeCompose,
Dependencies.viewModelSaveState,
Dependencies.lifecycleService,
)

internal val coroutinesAndroidDependencies = listOf(
Dependencies.kotlinCoroutines,
)

internal val coilImageLoadingDependencies = listOf(
Dependencies.coil,
)

internal val networkDependencies = listOf(
Dependencies.retrofit,
Dependencies.retrofitGsonConverter,
Dependencies.gson,
Dependencies.okhHttp3,
Dependencies.okhHttp3Interceptor,
Dependencies.rxJava3adapter,
)

//GroupedDepHandlerExtension.kt
package dependencies
import core.Dependencies
import org.gradle.api.artifacts.dsl.DependencyHandler

fun DependencyHandler.addAndroidComposeDependencies(){
androidComposeDependencies.forEach {
add("implementation",it)
}
}


fun DependencyHandler.addAndroLifeCycleDependencies(){
androidxLifeCycleDependencies.forEach {
add("implementation",it)
}
}


fun DependencyHandler.addCoroutinesAndroidDependencies(){
coroutinesAndroidDependencies.forEach {
add("implementation",it)
}
}

fun DependencyHandler.addCoilImageLoadingDependencies(){
coilImageLoadingDependencies.forEach {
add("implementation",it)
}
}

fun DependencyHandler.addNetworkDependencies(configurationName:String = "implementation"){
networkDependencies.forEach {
add(configurationName,it)
}
}

fun DependencyHandler.addHiltDependencies() {
add("implementation",Dependencies.hiltAndroid)
add("implementation",Dependencies.hiltNavCompose)
add("kapt",Dependencies.hiltCompiler)
}


fun DependencyHandler.addTimberDependencies(configurationName:String = "implementation"){
add(configurationName,Dependencies.timber)
}

fun DependencyHandler.addGsonDependencies(configurationName:String = "implementation"){
add(configurationName,Dependencies.gson)
}


fun DependencyHandler.addLeakcanaryDependencies(){
add("debugImplementation",Dependencies.leakcanary)
}

fun DependencyHandler.addAndroidTestsDependencies() {
add("testImplementation",Dependencies.jUnit)
add("androidTestImplementation",Dependencies.jUnitTestUi)
add("androidTestImplementation",Dependencies.jUnitExt)
add("androidTestImplementation",Dependencies.espresso)
add("debugImplementation",Dependencies.composeTooling)
add("debugImplementation",Dependencies.composeTestManifest)
}
//ModuleGroupedDependencies.kt
package dependencies
import core.*

internal val featureModule = listOf(
ModulesDep.repoList,
ModulesDep.profile,
)

//ModuleGroupedDepHandlerExtension.kt
package dependencies

import core.*
import org.gradle.api.artifacts.dsl.DependencyHandler
import org.gradle.kotlin.dsl.project

fun DependencyHandler.addDiModule(configurationName:String = "implementation"){
add(configurationName, project(ModulesDep.di))
}

fun DependencyHandler.addDomainModule(){
add("implementation", project(ModulesDep.domain))
}

fun DependencyHandler.addDataModule(){
add("implementation", project(ModulesDep.data))
}
fun DependencyHandler.addApiResponseModule(configurationName:String = "implementation"){
add(configurationName, project(ModulesDep.apiResponse))
}

fun DependencyHandler.addCommonModule(){
add("implementation", project(ModulesDep.common))
}

fun DependencyHandler.addEntityModule(configurationName:String = "implementation"){
add(configurationName, project(ModulesDep.entity))
}

fun DependencyHandler.addFeatureModule(){
featureModule.forEach {
add("implementation", project(it))
}
}

Next, create a plugin package, this package contains three files
1.android-base-library.gradle.kts [for all common module gradle dependency]
2.android-core-library.gradle.kts [for all core module gradle dependency]
3.android-feature-library.gradle.kts[for all feature module gradle dependency]
In this way, we can keep our dependencies are same in all the modules. Create the files and write the code.

//android-base-library.gradle.kts
package plugins
import dependencies.*

plugins {
id("com.android.library")
id ("org.jetbrains.kotlin.android")
kotlin("kapt")
}

android{
compileSdk = AppConfig.compileSdkVersion
defaultConfig {
minSdk = AppConfig.minSdkVersion
testInstrumentationRunner = AppConfig.testRunner
consumerProguardFiles("consumer-rules.pro")
vectorDrawables{
useSupportLibrary = true
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = "1.8"
}

buildFeatures{
compose = true
}

composeOptions {
kotlinCompilerExtensionVersion = AppConfig.kotlinCompilerExtensionVersion
}

packagingOptions {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
}
dependencies{
addAndroidComposeDependencies()
addCoroutinesAndroidDependencies()
addAndroidTestsDependencies()
}
//android-core-library.gradle.kts
package plugins
import dependencies.*

plugins {
id("com.android.library")
id ("org.jetbrains.kotlin.android")
kotlin("kapt")
id ("dagger.hilt.android.plugin")
}

android{
compileSdk = AppConfig.compileSdkVersion
defaultConfig {
minSdk = AppConfig.minSdkVersion
testInstrumentationRunner = AppConfig.testRunner
consumerProguardFiles("consumer-rules.pro")
vectorDrawables{
useSupportLibrary = true
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = "1.8"
}

buildFeatures{
compose = true
}

composeOptions {
kotlinCompilerExtensionVersion = AppConfig.kotlinCompilerExtensionVersion
}

packagingOptions {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
}

dependencies{
addAndroLifeCycleDependencies()
addCoroutinesAndroidDependencies()
addHiltDependencies()
addNetworkDependencies()
addAndroidTestsDependencies()
}
//android-feature-library.gradle.kts
package plugins
import dependencies.*

plugins {
id("com.android.library")
id ("org.jetbrains.kotlin.android")
kotlin("kapt")
id ("dagger.hilt.android.plugin")
}

android{
compileSdk = AppConfig.compileSdkVersion
defaultConfig {
minSdk = AppConfig.minSdkVersion
testInstrumentationRunner = AppConfig.testRunner
consumerProguardFiles("consumer-rules.pro")
vectorDrawables{
useSupportLibrary = true
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = "1.8"
}

buildFeatures{
compose = true
}

composeOptions {
kotlinCompilerExtensionVersion = AppConfig.kotlinCompilerExtensionVersion
}

packagingOptions {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
}

dependencies{
addDiModule()
addDomainModule()
addCommonModule()

addAndroidComposeDependencies()
addAndroLifeCycleDependencies()
addCoroutinesAndroidDependencies()
addCoilImageLoadingDependencies()
addHiltDependencies()
addAndroidTestsDependencies()
}

Next need to rename your project-level file settings.gradle to settings.gradle.kts and replace the code.

//settings.gradle.kts
pluginManagement {
repositories {
gradlePluginPortal()
mavenCentral()
mavenLocal()
google()
maven("https://jitpack.io")
maven("https://oss.jfrog.org/libs-snapshot")

}

gradle.projectsLoaded {
plugins{
plugins {
id ("com.android.application") version(extra.properties["androidGradlePluginVersion"].toString())
id ("com.android.library") version(extra.properties["androidGradlePluginVersion"].toString())
id ("org.jetbrains.kotlin.android") version(extra.properties["kotlinVersion"].toString())
id ("org.jetbrains.kotlin.jvm") version(extra.properties["kotlinVersion"].toString())
id ("com.google.dagger.hilt.android") version(extra.properties["hiltVersion"].toString())
}
}
}
}


dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
gradlePluginPortal()
mavenCentral()
mavenLocal()
google()
maven("https://jitpack.io")
maven("https://oss.jfrog.org/libs-snapshot")
}
}
rootProject.name = "JetPackComposeModularization"
include (":app")

Next need to rename your project-level file build.gradle to build.gradle.kts and replace the code.

//build.gradle.kts
plugins {
id ("com.android.application") apply(false)
id ("com.android.library") apply(false)
id ("org.jetbrains.kotlin.android") apply(false)
id ("com.google.dagger.hilt.android") apply(false)
id("org.jetbrains.kotlin.jvm") apply (false)
}

tasks.create<Delete>("clean") {
delete = setOf(
rootProject.buildDir
)
}
gradle.projectsLoaded{
configurations.all {
configurations.all {
resolutionStrategy {
eachDependency {
if ((requested.group == "org.jetbrains.kotlin") && (!requested.name.startsWith("kotlin-gradle"))) {
useVersion(extra.properties["kotlinVersion"].toString())
}
}
force(
"org.jetbrains.kotlin:kotlin-stdlib:${extra.properties["kotlinVersion"].toString()}",
"org.jetbrains.kotlin:kotlin-stdlib-common:${extra.properties["kotlinVersion"].toString()}",
"org.jetbrains.kotlin:kotlin-reflect:${extra.properties["kotlinVersion"].toString()}",
)
}
}
}
}

Next need to rename your app-level file build.gradle to build.gradle.kts and replace the code.

//build.gradle.kts
import dependencies.*
plugins {
id ("com.android.application")
id ("org.jetbrains.kotlin.android")
kotlin("kapt")
id ("dagger.hilt.android.plugin")
}

android {
compileSdk = AppConfig.compileSdkVersion
defaultConfig {
applicationId = AppConfig.applicationId
minSdk = AppConfig.minSdkVersion
targetSdk = AppConfig.targetSdkVersion
versionCode = AppConfig.versionCode
versionName = AppConfig.versionName
vectorDrawables{
useSupportLibrary = true
}
}

buildTypes {
debug {
isMinifyEnabled = false
isShrinkResources = false
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
}
debug {
isMinifyEnabled = false
isShrinkResources = false
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
}
}

compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}

kotlinOptions {
jvmTarget = "1.8"
}
buildFeatures{
compose = true
}

composeOptions {
kotlinCompilerExtensionVersion = AppConfig.kotlinCompilerExtensionVersion
}

packagingOptions {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
}

dependencies {
addDataModule()
addDomainModule()
addDiModule()
addFeatureModule()
addCommonModule()

addAndroidComposeDependencies()
addAndroLifeCycleDependencies()
addCoroutinesAndroidDependencies()
addHiltDependencies()
addNetworkDependencies()
addLeakcanaryDependencies()
addAndroidTestsDependencies()
}

Now sync your project. Congratulation!
Source Code: Github-Link

What next?

In the next chapter, we will apply the buildSrc plugin in the di, domain, and data modules and implement the module's code.

Second Part

You can find me on Linkedin!