Skip to content
ExponaBLOG
MODERN ANDROID & KOTLIN · DAY 060 / 100

Modularizing an Android app: what developers should know

Faster builds and clearer ownership.

Expona Admin6 min read

Modularizing an Android app: what developers should know

As your Android codebase grows, a monolithic structure becomes a bottleneck—slower builds, harder debugging, and unclear boundaries between features. Modularizing an Android app transforms how your team ships code: faster incremental builds, clearer ownership, and the ability to test features in isolation. This day 60 write-up walks through the practical decisions you need to make when splitting a monolith into well-organized modules.

Why modularization matters now

We've all worked on projects where a single change to a utility function triggers a rebuild of the entire app. Twenty minutes later, you're still waiting. With modularization, only the affected modules rebuild. On a medium-to-large team, this compounds: if your build takes 3 minutes versus 30 seconds, that's 45 minutes per developer per day reclaimed.

Beyond speed, modularization creates natural boundaries. When the payment feature lives in its own module, the team owning it knows exactly what code is theirs. The authentication module doesn't leak into the shopping cart. Contracts become explicit through module APIs, not implicit through shared packages.

Feature modules—One module per feature

The core principle: each user-facing feature gets its own module. Your app has a cart, search, checkout, and user profile? That's four feature modules, each with its own build.gradle.kts.

A typical feature module structure looks like this:

kotlin
cart/
├── build.gradle.kts
├── src/main/kotlin/
│   └── com/example/cart/
│       ├── CartScreen.kt
│       ├── CartViewModel.kt
│       ├── CartRepository.kt
│       └── di/
│           └── CartModule.kt
└── src/test/kotlin/
    └── com/example/cart/
        └── CartViewModelTest.kt

Each feature module is self-contained. It holds its UI layer (Compose or Views), business logic, and data handling. The module knows nothing about other features—it only depends on core modules for shared utilities.

In your feature module's build.gradle.kts, you declare what it needs:

kotlin
dependencies {
    implementation(project(":core:ui"))
    implementation(project(":core:data"))
    implementation(libs.androidx.compose.ui)
    implementation(libs.hilt)
}

This explicit dependency graph makes it impossible to accidentally import from the checkout feature when you're building the cart feature. The compiler won't allow it.

Core modules—Shared UI, data and utilities

Not every piece of code should live in a feature module. Shared components, network infrastructure, and database setup belong in core modules. Think of core modules as a foundation that all features build on top of.

A typical structure:

kotlin
core/
├── ui/          # Shared Compose components, themes, utilities
├── data/        # Repository interfaces, data models, database setup
├── network/     # HTTP client configuration, interceptors
├── logging/     # Logging utilities
└── di/          # Hilt global setup

The core:ui module holds your design system. Button styles, color palettes, typography, common dialog components—anything multiple features reuse lives here. If three features need a loading state indicator, it goes in core:ui.

kotlin
// core/ui/src/main/kotlin/com/example/core/ui/LoadingIndicator.kt
@Composable
fun LoadingIndicator(modifier: Modifier = Modifier) {
    CircularProgressIndicator(
        modifier = modifier.size(48.dp),
        color = AppTheme.colors.primary
    )
}

The core:data module handles API responses, database models, and repository setup. If your app talks to a backend, the HTTP client lives here. Database migrations? Also here. Individual features don't need to care how data is fetched—the repository interface handles that.

kotlin
// core/data/src/main/kotlin/com/example/core/data/CartRepository.kt
interface CartRepository {
    suspend fun getCart(): Result
    suspend fun addItem(itemId: String, quantity: Int): Result
}

// Implementation also in core:data, injected via Hilt
@Singleton
class CartRepositoryImpl(
    private val api: CartApi,
    private val db: AppDatabase
) : CartRepository {
    // ...
}

This separation means a feature module never directly touches your database or makes raw HTTP calls. It always goes through a repository, which is defined and implemented in core modules. That's your contract layer.

Dependency direction—Features depend on core, not each other

This is the critical rule: dependencies flow downward. Features depend on core. Core modules depend on third-party libraries and Android APIs. No feature ever imports from another feature.

Visualized:

Layer Can depend on Cannot depend on
Feature modules (cart, checkout) Core modules, libraries Other feature modules
Core modules Libraries, Android APIs Feature modules
Libraries Android APIs only Anything else

Why? If checkout imports from cart, and cart imports from checkout, you have a circular dependency. Your build breaks, and the modules are no longer independent. Circular dependencies also mean tight coupling—changes in one feature cascade into another.

When two features need to communicate, use an event bus or a shared data model in core, not direct imports. For example, if checkout needs to know when an item is added to the cart, use a shared ViewModel or publish an event through a core module's event dispatcher:

kotlin
// core/data/src/main/kotlin/com/example/core/data/CartEvents.kt
sealed class CartEvent {
    data class ItemAdded(val itemId: String, val quantity: Int) : CartEvent()
    data class ItemRemoved(val itemId: String) : CartEvent()
}

// Hilt-provided singleton
@Singleton
class CartEventBus {
    private val _events = MutableSharedFlow()
    val events: SharedFlow = _events.asSharedFlow()

    suspend fun emit(event: CartEvent) {
        _events.emit(event)
    }
}

Now the checkout feature can subscribe to cart events without importing the cart module directly. It only depends on core:data, which both features use.

Convention plugins—Share Gradle configuration across modules

With ten or more modules, duplicating build.gradle.kts is a maintenance nightmare. Every time you upgrade Compose or update a library version, you're editing multiple files. Convention plugins solve this.

Create a build-logic module at the project root:

kotlin
build-logic/
├── build.gradle.kts
├── src/main/kotlin/
│   └── com/example/
│       ├── AndroidFeatureConvention.kt
│       ├── AndroidCoreConvention.kt
│       └── Versions.kt

In AndroidFeatureConvention.kt, define what every feature module needs:

kotlin
// build-logic/src/main/kotlin/com/example/AndroidFeatureConvention.kt
class AndroidFeatureConvention : Plugin {
    override fun apply(target: Project) = with(target) {
        pluginManager.run {
            apply("com.android.library")
            apply("kotlin-android")
            apply("dagger.hilt.android.plugin")
        }

        extensions.configure {
            compileSdk = 34

            defaultConfig {
                minSdk = 24
                testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
            }

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

        dependencies {
            add("implementation", libs.findLibrary("androidx.core").get())
            add("implementation", libs.findLibrary("hilt.android").get())
            add("kapt", libs.findLibrary("hilt.compiler").get())
            add("testImplementation", libs.findLibrary("junit").get())
        }
    }
}

Then in any feature module's build.gradle.kts:

kotlin
plugins {
    id("com.example.android.feature")
}

That's it. All Compose dependencies, Hilt setup, and Android configuration come from the convention. You update versions in one place. All modules inherit the change.

Common mistakes

Mistake 1: Core modules that are too large. If your core:ui has 50 Compose components, split it further: core:ui:buttons, core:ui:dialogs. Granular modules build faster and are easier to reason about.

Mistake 2: Features importing features indirectly. You don't directly import checkout in cart, but cart imports a utility from core that checkout also imports. That's fine. But if you find yourself creating a core:cart-checkout module just to share code between two features, you're coupling them. Either put shared logic in core, or accept the duplication.

Mistake 3: Ignoring build-on-demand. With modularization, you can build and test individual modules without rebuilding the app. Use ./gradlew :cart:test during development. This is a game-changer for iteration speed.

Mistake 4: Over-modularizing too early. Start with 3-4 core modules and 5-7 feature modules. Once you hit 30+ modules, you're likely splitting things that don't need to be split. Modularization solves real problems; don't create it as busywork.

Key takeaways

  • Feature modules encapsulate user-facing functionality; core modules provide shared infrastructure and utilities.
  • Enforce unidirectional dependencies: features depend on core, never on each other.
  • Use event buses or shared data models in core when features need to communicate.
  • Convention plugins eliminate boilerplate and centralize version management across modules.
  • Modularization's primary wins are faster builds, clearer ownership, and easier testing in isolation.
  • Start simple. Grow your module structure as complexity actually demands it, not in anticipation.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles