Why and how would you split a growing Android app into multiple Gradle modules?
A monolithic :app module compiles as one blob and lets every screen reach into every other screen — modularization is how large Android teams buy back build speed and clear boundaries.
LEARN BY DOINGPut your intuition to work3 steps
Why and how would you split a growing Android app into multiple Gradle modules?
Use the lesson’s examples and explanation to check your idea.
Personal reflection. Nothing is sent to an AI service.#Why a single :app module stops scaling
Every Android project starts as one Gradle module, and for a while that's the right call — there's no boundary worth enforcing yet. But once a codebase grows past a handful of engineers and a few dozen screens, that single module starts working against you in three concrete ways. First, build times: Gradle can only parallelize and incrementally recompile at module granularity, so touching one file in a one-module app forces Gradle to reconsider recompiling and re-linking everything downstream of it, and every incremental build (and every clean CI build) pays for the whole app even when you changed one screen. Second, boundaries: without module walls, nothing stops the checkout screen from importing an internal class from the profile screen's package, so "internal" implementation details leak everywhere and refactoring becomes archaeology. Third, team ownership and CI: with one module, "run the tests" means "run all the tests," and code review has no natural seam that maps to "who owns this."
Splitting the app into Gradle modules attacks all three. Gradle's dependency graph becomes an explicit contract — a module can only see the public API of the modules it depends on — and Gradle's build cache and configuration cache can skip work for modules whose inputs haven't changed. That's the mechanical reason interviewers care about this: modularization isn't a style preference, it's a build performance feature with architectural boundaries as a side effect.
#The shape most teams converge on
The common layering — used by Google's own "Now in Android" sample and by most large production apps — has three kinds of modules:
:app— the thin composition root. It wires togetherApplication, dependency injection graph, and the navigation host. It should contain almost no business logic.:feature:*— one module per user-facing feature (:feature:home,:feature:checkout,:feature:profile). Each owns its own screens, ViewModels, and feature-specific logic.:core:*— shared, feature-agnostic building blocks::core:ui(design system, shared composables),:core:network(Retrofit/OkHttp setup),:core:database(Room),:core:common(utilities, base classes, no Android dependency where avoidable so it can be tested on the JVM).
The dependency rule is directional: features depend on core, never the other way around, and core modules never know a feature module exists. That single rule is what prevents the dependency graph from turning back into a monolith with extra folders.
Notice :feature:checkout doesn't depend on all of :feature:profile — it depends on a thin :feature:profile:api module that exposes only the interfaces and data classes checkout actually needs (say, interface ProfileSummaryProvider). The real :feature:profile module implements that interface and is wired together at the :app level via dependency injection. That extra hop is how you get feature-to-feature communication without two features compiling against each other's internals, and it's also what makes it possible to delete or replace a feature module without touching its neighbors.
#Mechanics that make it work in practice
Two Gradle details decide whether modularization actually pays off:
apivs.implementation. Declaring a dependency withimplementation(the default, and almost always the right choice) keeps it off your module's compile classpath as seen by downstream consumers — so if:core:network's implementation swaps OkHttp versions, only:core:networkrecompiles, not every feature that transitively depends on it. Reaching forapire-exposes that dependency to everything downstream and quietly reintroduces the coupling you modularized to avoid.- Convention plugins. Thirty feature modules each need nearly identical
android { compileSdk = ...; minSdk = ...; buildFeatures { compose = true } }blocks. Copy-pasting that thirty times means updating the SDK version is a thirty-file diff. A composite build (abuild-logicincluded build, the modern replacement forbuildSrc) defines a handful of Gradle convention plugins —android-feature,android-library-compose,jvm-library— that each module applies with one line, so shared build configuration lives in exactly one place.
#Beyond build speed: delivery
Modularization also unlocks Play Feature Delivery: a :feature:* module can be converted into a dynamic feature module, downloaded on demand instead of shipping inside the base APK/AAB — useful for a rarely-used feature (say, a one-time onboarding flow, or an AR try-on feature) that shouldn't bloat every user's install size. That's only possible once the feature already has a clean module boundary; it's one more reason interviewers treat modularization as a foundational architecture decision rather than a nice-to-have.