SYSONLINE
SDK36 · MIN 24
KOTLIN2.0
UTC22:22:48
UP00:01
MOD · ARTICLE · ARCHITECTURES/N · AX-ANDROIPUBLISHED
All Articles
Architecture · Updated 12 MIN

Android Modularization: Structuring Large-Scale Apps for Speed

Break a monolithic Android app into data, feature and common modules for clearer ownership, enforced boundaries and builds that can skip work.

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

When does a single module become a problem?

Every Android app starts as one module, and for a small team on a small app that is correct. Android's modularization guide says so in its own list of pitfalls: "It doesn't always make sense to modularize your project. A dominating factor is the size of the codebase." The problem it describes arrives with growth: "In an ever-growing codebase, scalability, readability, and overall code quality often decrease through time." The symptom you feel first is coupling. "In a tightly coupled codebase a single change can trigger a cascade of alterations in seemingly unrelated parts of code." Modularization is the structural answer: "a practice of organizing a codebase into loosely coupled and self contained parts." The guide separates the benefits into two groups, and the distinction is useful when you are arguing for the work. Reusability, strict visibility control and customizable delivery "are only achievable with a modularized codebase". Scalability, ownership, encapsulation, testability and build time "might be achieved with other techniques", though modules help enforce them. The earlier version of this article gave thresholds for when to start, a four-minute build among them. They were ours, with nothing behind them, and they are removed.

Module Types and the Dependency Graph

Android's common patterns page names the module types, and the earlier version of this article left one of them out. It described three layers: app, feature and core. The documented set has a fourth that carries most of the weight: data modules.
TypeWhat the guide says
Data"A data module usually contains a repository, data sources and model classes."
Feature"Feature modules depend on data modules."
App"App modules are an entry point to the application. They depend on feature modules and usually provide root navigation."
Common"Common modules, also known as core modules, contain code that other modules frequently use."
The guide also describes test modules, for test code that more than one module shares. Dependencies point one way: app to feature, feature to data, anything to common. Gradle will not let you do otherwise in a cycle, as the guide notes: "A direct, two-way communication between modules is impossible due to cyclic dependencies." We go one step further than the guide requires and allow no feature module to depend on another feature module at all. That rule is our convention, not Android's, and the navigation section shows what replaces the dependency. The principle underneath all of it is stated as a pair: "Low coupling means that modules should be as independent as possible from one another", and "High cohesion means that modules should comprise a collection of code that acts as a system."
kotlin
// settings.gradle.kts
include(":app")

// Feature modules: screens and their ViewModels
include(":feature:home")
include(":feature:search")
include(":feature:checkout")

// Data modules: one per domain, each exposing a repository
include(":data:books")
include(":data:orders")

// Common modules
include(":core:model")
include(":core:ui")
include(":core:network")
include(":core:testing")

Data Modules and the Model

A data module has three jobs in the guide, and two of them are about what it hides: "Expose the repository as an external API" and "Hide all implementation details and data sources from the outside". The rule that makes the second one real: "Data sources should only be accessible by repositories from the same module." The earlier version of this article put repository interfaces in a shared :core:model module and called that module the glue between features. That is a workable design, but it is not what the guide describes, and it pushes every contract in the app into one module that everything depends on. A change to any of them recompiles everything. Keep the repository with its data module. Keep :core:model for the plain types that cross module boundaries. Those plain types do not need Android. The guide's advice on module kind is direct: "Since Android modules come with overhead, preferably, you'd want to use the Kotlin or Java kind as much as possible." A model module is the easiest place to follow it.
kotlin
// core/model/build.gradle.kts: a plain Kotlin module
plugins {
    kotlin("jvm")
}

// :core:model
data class Book(
    val id: String,
    val title: String,
    val priceInCents: Long,
)

// :data:books, the module's public API
interface BookRepository {
    suspend fun getBook(id: String): Book
    fun observeBooks(): Flow<List<Book>>
}

// :data:books, hidden from every other module
internal class NetworkBookDataSource(/* ... */)
internal class DefaultBookRepository(
    private val network: NetworkBookDataSource,
) : BookRepository {
    override suspend fun getBook(id: String): Book = TODO()
    override fun observeBooks(): Flow<List<Book>> = TODO()
}

Feature Module Structure

A feature, in the guide's definition, is "an isolated part of an app's functionality that usually corresponds to a screen or series of closely related screens, like a sign up or checkout flow." What a feature module exposes should be close to nothing. "The public interface of a module should be minimal and expose only the essentials." The tool is one keyword. Kotlin's visibility documentation defines it: "If you mark it as internal, it will be visible everywhere in the same module", and a module is "a set of Kotlin files compiled together". A Gradle module is one such set, so internal on a ViewModel means no other module can touch it. The same restraint applies to dependencies. "When declaring dependencies in your module, prefer implementation over api." What that does to build time is in the Gradle build optimization guide.
kotlin
// feature/checkout/
//   build.gradle.kts
//   src/main/kotlin/com/app/feature/checkout/
//     CheckoutScreen.kt      public: the entry point
//     CheckoutViewModel.kt   internal
//     CheckoutUiState.kt     internal

// CheckoutScreen.kt: the only public declaration
@Composable
fun CheckoutScreen(
    onOrderPlaced: (orderId: String) -> Unit,
) {
    // ...
}

internal class CheckoutViewModel(/* ... */) : ViewModel()

// feature/checkout/build.gradle.kts
dependencies {
    implementation(project(":data:books"))
    implementation(project(":data:orders"))
    implementation(project(":core:model"))
    implementation(project(":core:ui"))
    // No other feature module appears here
    testImplementation(project(":core:testing"))
}

Build Time: What Modules Buy and What They Cost

The earlier version of this article promised a 40 to 60% reduction in incremental build time. No source says that, and the number is removed from the text and from the takeaways. What the guide claims is narrower: "Some Gradle functionalities such as incremental build, build cache or parallel build, can leverage modularity to improve build performance." Can, not will. Gradle's performance guide gives the condition for parallel execution: "If a single subproject dominates execution time or there are many dependencies between subprojects, the benefits will be minimal." A module that depends on everything rebuilds when anything changes. Modules also cost something, and the guide counts it as a pitfall: "Every module brings a certain amount of overhead in the form of increased build complexity and boilerplate code." Its remedy is the one this site has a full article on: "Use convention plugins to share build logic between modules." With one in place, a feature module's build file is a few lines, and the shared decisions live in one file. The setup, including what changed in Android Gradle plugin 9, is in the version catalogs and convention plugins guide. The sample that used to be here was a plugin class in buildSrc that applied the kotlin-android plugin. AGP 9 rejects that plugin, so the sample would fail today.
kotlin
// feature/checkout/build.gradle.kts, with a convention plugin
plugins {
    // compileSdk, minSdk and Java level are set once, here
    id("android-library-conventions")
}

android {
    namespace = "com.app.feature.checkout"
}

Navigation Across Module Boundaries

If the home feature cannot depend on the checkout feature, something else has to connect them. The guide's answer is a mediator: "you can have a third module mediating between two other modules." For navigation, "the mediator is the module that owns the navigation graph (usually an app module)." In practice a feature exposes a callback and knows nothing about where it leads. The app module supplies the callback and performs the navigation. The sample is the guide's own, a book tapped on the home screen opening checkout. What crosses the boundary matters as much as how. "You shouldn't pass objects as navigation arguments. Instead, use simple ids that features can use to access and load desired resources from the data layer." The destination loads the book from its repository by id. That is also why the repository lives in a data module both features can depend on. The route here is a plain string because the guide's sample is. Typed routes, and the state of the Navigation libraries in 2026, are covered in the type-safe navigation guide.
kotlin
// :feature:home knows nothing about checkout
@Composable
fun HomeScreen(
    onBookSelected: (bookId: String) -> Unit,
) {
    // ...
}

// :app owns the navigation graph, so it is the mediator
HomeScreen(
    onBookSelected = { bookId ->
        navController.navigate("checkout/$bookId")
    }
)

// :feature:checkout reads the id and loads the book itself
internal class CheckoutViewModel(
    savedStateHandle: SavedStateHandle,
    bookRepository: BookRepository,
) : ViewModel() {

    val bookId: StateFlow<String> =
        savedStateHandle.getStateFlow<String>("bookId", "")
}
MOD · TAKEAWAYS6 POINTSSUMMARY

Key Takeaways

  1. 1Modularize when the codebase is large enough to need it. Android says it does not always make sense.
  2. 2Four module types: data, feature, app and common. Feature modules depend on data modules, and the app module depends on features.
  3. 3A data module exposes a repository and hides its data sources. Keep the repository with its data, not in a shared model module.
  4. 4Mark everything internal except the entry point, and prefer implementation over api.
  5. 5Modules can improve build time through incremental build, the build cache and parallel build. They also add configuration overhead, which convention plugins contain.
  6. 6Features talk through a mediator, usually the app module, and pass ids, not objects.
MOD · FAQ3 ENTRIESANSWERED

Frequently Asked

When should I modularize my Android app?

Android gives a clear test. If you need reusability across apps, strict visibility control or Play Feature Delivery, modularization is a necessity, because those benefits are only achievable with modules. If you want better scalability, ownership, encapsulation or build times, it is worth considering. If the codebase will stay small, the gains do not apply.

How many modules is too many?

There is no number. Android describes two failure modes: too fine-grained, where every module adds build complexity and boilerplate until the overhead outweighs the benefit, and too coarse-grained, where a module grows into another monolith. Start with clear boundaries, and consolidate modules when the configuration cost exceeds what the split gives you.

Can feature modules depend on each other?

Not in both directions: Gradle does not allow cyclic dependencies between modules. We forbid it in either direction as a convention. Android recommends a mediator instead, usually the app module that owns the navigation graph, which passes a simple id from one feature to the next so that neither knows about the other.

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