Your single-module app compiles in 45 seconds. You add more features. More screens. More dependencies. Now it takes 3 minutes. Every small change triggers a full rebuild.
Worse, any file can import any other file. Your UI layer imports database classes directly. A junior developer bypasses the repository and calls the DAO from a Composable. There are no boundaries.
Multi-module architecture fixes both problems. Faster builds (Gradle only recompiles changed modules) and enforced boundaries (modules can only access what they depend on).
Prerequisites: Tutorial #1: Architecture, Tutorial #2: Hilt, and Tutorial #4: Use Cases.
Why Multi-Module?
Build Speed
In a single-module app, changing one file recompiles everything. In a multi-module app, Gradle only recompiles the changed module and modules that depend on it.
Single module: change TaskDao.kt → recompile entire app (3 min)
Multi-module: change TaskDao.kt → recompile core-data (15 sec) + app (10 sec)
Enforced Boundaries
In a single module, nothing stops you from importing TaskDao in a Composable. In a multi-module app, if feature-tasks does not depend on core-data, it physically cannot import TaskDao.
Team Independence
Team A works on feature-tasks. Team B works on feature-users. They don’t touch each other’s code. Merge conflicts drop dramatically.
KMP Readiness
If you structure your domain and data modules without Android dependencies, extracting them for Kotlin Multiplatform becomes straightforward.
Module Types
A typical multi-module app has five types of modules:
app/ → Entry point, wires everything together
feature-tasks/ → Task list, task detail screens
feature-users/ → User profile, settings screens
core-data/ → Repositories, DAOs, API services
core-network/ → Retrofit, OkHttp configuration
core-domain/ → Use cases, domain models, repository interfaces
core-ui/ → Shared Compose components (optional)
Module Dependency Graph
┌─────┐
│ app │
└──┬──┘
┌───────┼───────┐
▼ ▼ ▼
┌──────────┐ ┌──────────┐
│ feature- │ │ feature- │
│ tasks │ │ users │
└────┬─────┘ └────┬─────┘
│ │
▼ ▼
┌──────────────────────┐
│ core-domain │ ← Use cases, interfaces
└──────────┬───────────┘
│
┌──────────┴───────────┐
│ core-data │ ← Implementations
└──────────┬───────────┘
┌──────┴──────┐
▼ ▼
┌────────────┐ ┌──────────┐
│ core-network│ │ (Room) │
└────────────┘ └──────────┘
Key rules:
- Feature modules depend on
core-domain, NOT oncore-data core-dataimplements interfaces fromcore-domain- Feature modules never depend on other feature modules
core-domainhas no Android dependencies
Setting Up Modules
Step 1: Create Module Directories
my-app/
├── app/
├── core/
│ ├── data/
│ ├── domain/
│ ├── network/
│ └── ui/
├── feature/
│ ├── tasks/
│ └── users/
├── settings.gradle.kts
└── gradle/
└── libs.versions.toml
Step 2: settings.gradle.kts
rootProject.name = "MyApp"
include(":app")
include(":core:data")
include(":core:domain")
include(":core:network")
include(":core:ui")
include(":feature:tasks")
include(":feature:users")
Step 3: Module build.gradle.kts Files
core/domain/build.gradle.kts — pure Kotlin, no Android:
plugins {
alias(libs.plugins.kotlin.jvm)
}
dependencies {
implementation(libs.kotlinx.coroutines.core)
implementation(libs.javax.inject) // For @Inject annotation
testImplementation(libs.junit)
testImplementation(libs.kotlinx.coroutines.test)
}
core/data/build.gradle.kts — Android library with Room:
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.ksp)
alias(libs.plugins.hilt)
}
android {
namespace = "com.kemalcodes.myapp.core.data"
// ...
}
dependencies {
implementation(project(":core:domain"))
implementation(project(":core:network"))
implementation(libs.room.runtime)
implementation(libs.room.ktx)
ksp(libs.room.compiler)
implementation(libs.hilt.android)
ksp(libs.hilt.compiler)
implementation(libs.datastore.preferences)
}
core/network/build.gradle.kts — Retrofit and OkHttp:
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.ksp)
alias(libs.plugins.hilt)
alias(libs.plugins.kotlin.serialization)
}
android {
namespace = "com.kemalcodes.myapp.core.network"
}
dependencies {
implementation(libs.retrofit)
implementation(libs.retrofit.kotlinx.serialization)
implementation(libs.okhttp)
implementation(libs.okhttp.logging)
implementation(libs.kotlinx.serialization.json)
implementation(libs.hilt.android)
ksp(libs.hilt.compiler)
}
feature/tasks/build.gradle.kts — Compose feature:
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.ksp)
alias(libs.plugins.hilt)
}
android {
namespace = "com.kemalcodes.myapp.feature.tasks"
buildFeatures { compose = true }
}
dependencies {
implementation(project(":core:domain"))
implementation(project(":core:ui"))
// Compose
implementation(platform(libs.compose.bom))
implementation(libs.compose.ui)
implementation(libs.compose.material3)
implementation(libs.lifecycle.viewmodel.compose)
implementation(libs.lifecycle.runtime.compose)
// Hilt
implementation(libs.hilt.android)
ksp(libs.hilt.compiler)
implementation(libs.hilt.navigation.compose)
}
Notice: feature:tasks depends on core:domain but NOT on core:data. It cannot access Room DAOs or Retrofit APIs.
app/build.gradle.kts — wires everything:
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.ksp)
alias(libs.plugins.hilt)
}
dependencies {
implementation(project(":core:data"))
implementation(project(":core:domain"))
implementation(project(":core:network"))
implementation(project(":core:ui"))
implementation(project(":feature:tasks"))
implementation(project(":feature:users"))
implementation(libs.hilt.android)
ksp(libs.hilt.compiler)
// Navigation
implementation(libs.navigation.compose)
implementation(libs.hilt.navigation.compose)
}
Version Catalog
Manage all dependency versions in one place:
# gradle/libs.versions.toml
[versions]
agp = "8.10.1"
kotlin = "2.1.21"
ksp = "2.1.21-2.0.5"
hilt = "2.57.1"
room = "2.8.4"
retrofit = "3.0.0"
okhttp = "5.3.0"
composeBom = "2025.06.00"
coroutines = "1.10.2"
serialization = "1.8.1"
navigation = "2.9.0"
datastore = "1.2.1"
lifecycle = "2.9.1"
[libraries]
# Compose
compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
compose-ui = { group = "androidx.compose.ui", name = "ui" }
compose-material3 = { group = "androidx.compose.material3", name = "material3" }
# Lifecycle
lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" }
lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycle" }
# Navigation
navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigation" }
# Room
room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }
room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
# Hilt
hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" }
hilt-compiler = { group = "com.google.dagger", name = "hilt-compiler", version.ref = "hilt" }
hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version = "1.2.0" }
# Network
retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" }
retrofit-kotlinx-serialization = { group = "com.squareup.retrofit2", name = "converter-kotlinx-serialization", version.ref = "retrofit" }
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
okhttp-logging = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttp" }
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "serialization" }
# DataStore
datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" }
# Coroutines
kotlinx-coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "coroutines" }
kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutines" }
# DI (for domain module without Android)
javax-inject = { group = "javax.inject", name = "javax.inject", version = "1" }
# Test
junit = { group = "junit", name = "junit", version = "4.13.2" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
android-library = { id = "com.android.library", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
Every module references the same version catalog. No version conflicts.
Convention Plugins
When you have 7+ modules, duplicating build logic in every build.gradle.kts gets tedious. Convention plugins share common configuration.
Step 1: Create build-logic Module
my-app/
├── build-logic/
│ ├── convention/
│ │ ├── build.gradle.kts
│ │ └── src/main/kotlin/
│ │ ├── AndroidLibraryConventionPlugin.kt
│ │ └── AndroidFeatureConventionPlugin.kt
│ └── settings.gradle.kts
Step 2: Include build-logic in Root settings.gradle.kts
// settings.gradle.kts (root)
pluginManagement {
includeBuild("build-logic")
}
Step 3: Convention Plugin
// build-logic/convention/src/main/kotlin/AndroidFeatureConventionPlugin.kt
class AndroidFeatureConventionPlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
pluginManager.apply {
apply("com.android.library")
apply("org.jetbrains.kotlin.android")
apply("org.jetbrains.kotlin.plugin.compose")
apply("com.google.dagger.hilt.android")
apply("com.google.devtools.ksp")
}
extensions.configure<LibraryExtension> {
compileSdk = 36
defaultConfig.minSdk = 26
buildFeatures {
compose = true
}
}
dependencies {
add("implementation", project(":core:domain"))
add("implementation", project(":core:ui"))
// Add common feature dependencies
add("implementation", libs.findLibrary("hilt.android").get())
add("ksp", libs.findLibrary("hilt.compiler").get())
add("implementation", libs.findLibrary("hilt.navigation.compose").get())
}
}
}
}
Step 4: Use It
// feature/tasks/build.gradle.kts
plugins {
id("myapp.android.feature")
}
android {
namespace = "com.kemalcodes.myapp.feature.tasks"
}
// All common config is inherited!
dependencies {
// Only feature-specific dependencies here
}
Convention plugins are optional for small projects. But for 5+ modules, they save significant duplication.
Navigation Across Feature Modules
Feature modules don’t depend on each other. So how does feature-tasks navigate to feature-users?
Type-Safe Routes with @Serializable
Since Navigation 2.8+, you can use @Serializable classes as routes:
// core/domain/navigation/Routes.kt (shared module)
@Serializable
data object TaskList
@Serializable
data class TaskDetail(val taskId: Long)
@Serializable
data object UserProfile
@Serializable
data class UserDetail(val userId: Long)
Each Feature Provides Its NavGraph
// feature/tasks/navigation/TaskNavigation.kt
fun NavGraphBuilder.taskNavGraph(
onNavigateToUser: (Long) -> Unit
) {
composable<TaskList> {
TaskListScreen(
onUserClick = onNavigateToUser
)
}
composable<TaskDetail> { backStackEntry ->
val route = backStackEntry.toRoute<TaskDetail>()
TaskDetailScreen(taskId = route.taskId)
}
}
App Module Wires Navigation
// app/navigation/AppNavigation.kt
@Composable
fun AppNavigation() {
val navController = rememberNavController()
NavHost(navController = navController, startDestination = TaskList) {
taskNavGraph(
onNavigateToUser = { userId ->
navController.navigate(UserDetail(userId))
}
)
userNavGraph(
onNavigateBack = { navController.popBackStack() }
)
}
}
Feature modules expose navigation functions. The app module connects them.
Hilt in Multi-Module
Each module defines its own Hilt modules. Hilt merges them at compile time.
// core/network/di/NetworkModule.kt
@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
@Provides
@Singleton
fun provideRetrofit(): Retrofit { /*...*/ }
}
// core/data/di/DataModule.kt
@Module
@InstallIn(SingletonComponent::class)
abstract class DataModule {
@Binds
@Singleton
abstract fun bindTaskRepository(impl: TaskRepositoryImpl): TaskRepository
}
// feature/tasks/viewmodel/TaskViewModel.kt
@HiltViewModel
class TaskViewModel @Inject constructor(
private val getTasks: GetTasksUseCase // From core:domain
) : ViewModel()
The app module has @HiltAndroidApp. It depends on all modules, so Hilt sees every @Module and @Provides.
API Visibility
Use Kotlin’s internal keyword to hide implementation details:
// core/data — this class is internal, not visible outside core:data
internal class TaskRepositoryImpl @Inject constructor(
private val dao: TaskDao,
private val api: TaskApi
) : TaskRepository {
// ...
}
Only the interface TaskRepository (in core:domain) is public. Feature modules cannot create TaskRepositoryImpl directly.
For Hilt @Binds to work with internal classes, both the module and the implementation must be in the same Gradle module.
Common Mistakes
1. Circular Dependencies
feature-tasks depends on feature-users
feature-users depends on feature-tasks // Gradle won't allow this
Solution: move shared code to a core module.
2. Feature Module Depends on core-data
// feature/tasks/build.gradle.kts
dependencies {
implementation(project(":core:data")) // Wrong! Breaks layer separation
}
Feature modules should depend on core:domain only. The app module provides the actual implementations through Hilt.
3. Leaking Implementation Details
// core/data
class TaskRepositoryImpl( // Should be internal!
val dao: TaskDao // Should be private!
)
Make implementations internal and fields private.
KMP Readiness
If you plan to share code with iOS later, structure your modules for extraction:
core:domain— pure Kotlin (no Android imports). This can become a KMP shared module.core:data— Room + Retrofit. Room already supports KMP (Room 2.7+).- Feature modules — Android-specific Compose code. These stay on Android.
The multi-module structure this tutorial teaches is already KMP-ready. When you are ready, check the KMP tutorial series for step-by-step migration.
Build Performance Tips
Use
apivsimplementationcorrectly —implementationhides transitive dependencies. Useapionly when you want dependents to see a dependency.Enable Gradle build cache — add to
gradle.properties:org.gradle.caching=true org.gradle.parallel=trueUse configuration cache —
org.gradle.configuration-cache=trueingradle.properties.Avoid unnecessary dependencies — each dependency your module includes gets compiled. Keep modules lean.
What’s Next?
In this tutorial, you learned:
- Why multi-module improves build speed and code quality
- Module types: app, feature, core-data, core-domain, core-network
- Setting up modules with Gradle and version catalogs
- Convention plugins for shared build logic
- Navigation across feature modules with type-safe routes
- Hilt setup in multi-module projects
- API visibility with
internalkeyword
Next up: Android Tutorial #6: Retrofit + Kotlin Serialization — where you will build a production-ready API layer with interceptors, authentication, and error handling.
Related Articles
- Android Tutorial #1: Architecture — Clean Architecture overview
- Android Tutorial #2: Hilt — dependency injection
- Android Tutorial #4: Use Cases — domain layer
- Compose Tutorial #8: Navigation — Compose navigation basics
This is part 5 of the Android Development Tutorial series.