Android Modularization: Structuring Large-Scale Apps for Speed

Break monolithic Android apps into feature modules for faster builds, clearer ownership, and independent deployability.

Introduction

Modularization splits monolithic apps into smaller, focused modules. Benefits include faster builds through parallelization, clearer ownership boundaries, and the ability to deliver features dynamically. This guide covers practical modularization patterns.

Types of Modules

app module for application entry point. feature modules for user-facing features. core modules for shared code (core-ui, core-data, core-network). library modules for reusable components. Each type has different dependencies and purposes.

Defining Module Boundaries

Group by feature, not by layer. A feature module contains its UI, ViewModel, and data layer. Use interface modules for cross-feature communication. Keep common code in core modules but avoid god modules.

Navigation Between Modules

Use deep links for inter-module navigation. Create navigation interfaces in api modules. Avoid direct dependencies between feature modules. Consider navigation graph decomposition for large apps.

Build Performance

Configure Gradle for parallel builds. Use build cache effectively. Measure build times with build scan. Modularization enables parallel compilation and incremental builds.

Frequently Asked Questions

When should I modularize my app?

Consider modularization when: build times exceed 3-4 minutes, team grows beyond 5-6 developers, or you need dynamic feature delivery. Don't modularize prematurely - it adds complexity.

How many modules is too many?

There's no hard limit, but each module adds build configuration overhead. Start with 5-8 well-defined modules. Add modules when you have clear boundaries, not to hit a number.

SYSONLINE
SDK36 · MIN 24
KOTLIN2.0
UTC07:38:07
UP00:01
MOD · ARTICLE · ARCHITECTURES/N · AX-ANDROIPUBLISHED
ArchitectureFeb 20, 202613 MIN

Android Modularization: Structuring Large-Scale Apps for Speed

Break monolithic Android apps into feature modules for faster builds, clearer ownership, and independent deployability. Practical patterns and module layouts.

By Rocky Elsalaymeh · Founder & Principal Consultant, Strategia-X

When a Monolith Becomes a Problem

Every Android app starts as a single module. For small teams working on small apps, this is the right choice -- the overhead of multi-module architecture isn't worth it when one person can hold the entire codebase in their head. But apps grow. When your build takes 4 minutes for a one-line change, when merging feature branches causes daily conflicts, when a change in the payments module breaks the onboarding flow, and when new developers take weeks to understand the code structure -- the monolith is costing you. Modularization addresses these problems by splitting the app into independent modules with explicit boundaries. Each module compiles separately (enabling Gradle parallelism and caching), owns its public API surface, and can be developed and tested in isolation.

Module Types and the Dependency Graph

A well-structured multi-module app uses three layers of modules: **:app** -- The shell module. Contains only the Application class, the main Activity, and top-level navigation. Depends on all feature modules. **:feature:*** -- Feature modules. Each encapsulates a complete user-facing feature: its screens, ViewModels, and feature-specific logic. Feature modules never depend on each other. **:core:*** -- Shared infrastructure modules. Common code that multiple features need: networking, database, design system, analytics. Feature modules depend on core modules, never the reverse. The critical rule: the dependency graph must be a directed acyclic graph (DAG). No circular dependencies. No feature-to-feature dependencies. Features communicate through the :app module's navigation layer or through shared :core interfaces. Google's guide to Android app modularization covers these patterns in detail.
kotlin
// settings.gradle.kts
include(":app")

// Feature modules
include(":feature:auth")
include(":feature:home")
include(":feature:profile")
include(":feature:search")
include(":feature:checkout")

// Core modules
include(":core:network")
include(":core:database")
include(":core:ui")
include(":core:model")
include(":core:common")
include(":core:testing")

The :core:model Module

The :core:model module contains shared data classes and interfaces that define contracts between modules. It has zero Android dependencies -- pure Kotlin only. This makes it fast to compile and usable in Kotlin Multiplatform projects. Every feature module depends on :core:model for shared types. This is the glue that lets features communicate without depending on each other directly.
kotlin
// :core:model -- pure Kotlin, no Android dependencies
// build.gradle.kts
plugins {
    id("org.jetbrains.kotlin.jvm")
    id("kotlinx-serialization")
}

// Shared data types
@Serializable
data class User(
    val id: String,
    val displayName: String,
    val email: String,
    val avatarUrl: String?,
    val createdAt: Instant,
)

// Repository interfaces -- implementations live in
// feature or core modules
interface UserRepository {
    suspend fun getUser(id: String): User
    fun observeUser(id: String): Flow<User>
    suspend fun updateUser(user: User)
}

Feature Module Structure

Each feature module follows a consistent internal structure. The public API surface is minimal -- usually just a navigation route and any shared types. Everything else is internal. Use Kotlin's internal visibility modifier aggressively. ViewModels, repositories, mappers, and composables that are specific to the feature should all be internal. This prevents other modules from accidentally depending on implementation details.
kotlin
// :feature:profile module structure
// feature/profile/
//   build.gradle.kts
//   src/main/kotlin/com/app/feature/profile/
//     ProfileNavigation.kt       -- PUBLIC: navigation route
//     ProfileScreen.kt           -- internal composable
//     ProfileViewModel.kt        -- internal
//     ProfileUiState.kt          -- internal
//     data/
//       ProfileRepositoryImpl.kt -- internal
//     di/
//       ProfileModule.kt         -- Hilt module

// ProfileNavigation.kt -- the only public API
fun NavGraphBuilder.profileScreen(
    onNavigateToSettings: () -> Unit,
    onNavigateToEditProfile: () -> Unit,
) {
    composable(
        route = "profile/{userId}"
    ) { backStackEntry ->
        val userId = backStackEntry.arguments
            ?.getString("userId") ?: return@composable
        ProfileScreen(
            userId = userId,
            onNavigateToSettings = onNavigateToSettings,
            onNavigateToEditProfile = onNavigateToEditProfile,
        )
    }
}

// build.gradle.kts for :feature:profile
dependencies {
    implementation(project(":core:model"))
    implementation(project(":core:network"))
    implementation(project(":core:ui"))
    implementation(project(":core:common"))
    // No feature-to-feature dependencies!
    testImplementation(project(":core:testing"))
}

Build Performance Gains

The primary motivation for modularization is build speed. Gradle can compile independent modules in parallel and cache modules that haven't changed. When you modify a file in :feature:profile, only that module recompiles -- not the entire app. Real-world impact depends on module count and sizes, but teams typically see 40-60% reduction in incremental build times after modularizing. Google's Now in Android sample app demonstrates this structure at scale. The improvement compounds as the app grows: adding new features as new modules doesn't slow down existing builds. To maximize caching, configure Gradle's build cache and ensure your modules have clean dependency boundaries. A module that depends on everything will recompile whenever anything changes, defeating the purpose.
kotlin
# gradle.properties -- optimize for multi-module builds
org.gradle.parallel=true
org.gradle.caching=true
org.gradle.configuration-cache=true
org.gradle.jvmargs=-Xmx4g -XX:+UseParallelGC

# Convention plugins: share build logic without copy-paste
# buildSrc/src/main/kotlin/AndroidFeaturePlugin.kt
class AndroidFeaturePlugin : Plugin<Project> {
    override fun apply(target: Project) {
        with(target) {
            pluginManager.apply {
                apply("com.android.library")
                apply("org.jetbrains.kotlin.android")
                apply("com.google.dagger.hilt.android")
                apply("com.google.devtools.ksp")
            }
            extensions.configure<LibraryExtension> {
                compileSdk = 35
                defaultConfig.minSdk = 24
                buildFeatures.compose = true
            }
            dependencies {
                add("implementation", project(":core:model"))
                add("implementation", project(":core:ui"))
                add("testImplementation",
                    project(":core:testing"))
            }
        }
    }
}

Navigation Across Module Boundaries

Feature modules can't reference each other's composables directly. Navigation between features must go through the :app module, which knows about all features and wires their navigation graphs together. The pattern: each feature module exposes a NavGraphBuilder extension function that registers its routes. The :app module calls all of these in a single NavHost, passing navigation callbacks that connect the features. No feature knows about any other feature -- they just invoke callback lambdas.
kotlin
// :app module -- wires all feature navigation
@Composable
fun AppNavHost(navController: NavHostController) {
    NavHost(
        navController = navController,
        startDestination = "home"
    ) {
        homeScreen(
            onNavigateToProfile = { userId ->
                navController.navigate("profile/$userId")
            },
            onNavigateToSearch = {
                navController.navigate("search")
            }
        )

        profileScreen(
            onNavigateToSettings = {
                navController.navigate("settings")
            },
            onNavigateToEditProfile = {
                navController.navigate("edit-profile")
            }
        )

        searchScreen(
            onNavigateToResult = { resultId ->
                navController.navigate("detail/$resultId")
            }
        )
    }
}
MOD · TAKEAWAYS6 POINTSSUMMARY

Key Takeaways

  1. 1Modularize when build times, merge conflicts, or code coupling become bottlenecks.
  2. 2Three module layers: :app (shell), :feature:* (screens), :core:* (shared infrastructure).
  3. 3Feature modules never depend on each other -- communicate through :core:model interfaces.
  4. 4Use internal visibility aggressively to enforce module boundaries.
  5. 5Convention plugins eliminate duplicate build configuration across modules.
  6. 6Expect 40-60% incremental build time reduction with proper modularization.
MOD · FAQ2 ENTRIESANSWERED

Frequently Asked

When should I modularize my app?

Consider modularization when: build times exceed 3-4 minutes, team grows beyond 5-6 developers, or you need dynamic feature delivery. Don't modularize prematurely - it adds complexity.

How many modules is too many?

There's no hard limit, but each module adds build configuration overhead. Start with 5-8 well-defined modules. Add modules when you have clear boundaries, not to hit a number.

MOD · BUILD · NEXTS/N · AX-CTA-0001READY

Ready to architect your next Android app?

ANDROID-ARCHITECT generates production-ready Kotlin code, architecture blueprints, and CI/CD configurations from plain-language descriptions. Start building for free.

ANDROID-ARCHITECT · CONSOLE
S/N · AX-1A-00001
STRATEGIA-X · ENGINEERED · IN · CALIFORNIA