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 on core-data
  • core-data implements interfaces from core-domain
  • Feature modules never depend on other feature modules
  • core-domain has 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.


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

  1. Use api vs implementation correctlyimplementation hides transitive dependencies. Use api only when you want dependents to see a dependency.

  2. Enable Gradle build cache — add to gradle.properties:

    org.gradle.caching=true
    org.gradle.parallel=true
    
  3. Use configuration cacheorg.gradle.configuration-cache=true in gradle.properties.

  4. 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 internal keyword

Next up: Android Tutorial #6: Retrofit + Kotlin Serialization — where you will build a production-ready API layer with interceptors, authentication, and error handling.



This is part 5 of the Android Development Tutorial series.