- Why modularization accelerates teams and reduces risk
- How to define module boundaries and enforce layer separation
- Gradle techniques to shrink build times and manage variants
- CI/CD patterns and testing strategies for multi-module apps
- Practical checklist and step-by-step incremental migration plan
Monolithic apps slow teams more reliably than bad UI code: long builds, tangled dependencies, and release regressions precede every velocity problem. The lever you can pull that pays biggest dividends is disciplined modularization—bounded feature modules, a lean Gradle surface, and CI that treats modules as first-class citizens.
You see the symptoms every week: single-file changes triggering huge builds, teams blocked on a core module, flaky integration tests that only surface after merge, and pull requests that take hours to validate. Those are not purely process problems — they are architectural signals: coupling is implicit, Gradle configuration is unoptimized, and the CI pipeline runs everything because the system can't cheaply know what actually needs verification.
Why modularization accelerates teams and reduces risk
-
Parallel development with reduced blast radius. When features live in vertically scoped
:feature-xxxmodules and depend on a small:coreor:apisurface, teams can land feature work independently and run module-local tests quickly. This reduces merge friction and shortens feedback loops. - Faster incremental builds and safer CI. Smaller modules reduce Java/Kotlin compile inputs, and when combined with a shared remote build cache you avoid re-executing expensive tasks on CI and developer machines. Enabling the Gradle build cache produces measurable savings in repeated runs.
- Stronger ownership and easier onboarding. A module boundary makes the public API explicit; owners have a narrower surface to review and test. The repository pattern and a single source of truth for data flow make reasoning about correctness simpler.
- Reality check: modularization has upfront cost. A poor decomposition (dozens of tiny modules with circular dependencies) raises configuration overhead and increases the number of Gradle projects the tool must configure. Good modularization reduces total cost; naive or premature splitting can make things worse. Use profiling and limits on module granularity to avoid over-fragmentation.
Important: Non-transitive
Rclasses and annotation-processor choices can change incrementality dramatically; adopt namespaced R classes and prefer KSP overkaptwhere supported to reduce compile time and AAPT work.
How to define module boundaries and enforce layer separation
Start with a vertical decomposition: features are vertical slices that encapsulate UI, navigation, and feature-level orchestration. Shared concerns go into cross-cutting modules with explicit APIs.
Common module taxonomy (example):
| Module type | Purpose | Rules |
|---|---:|---|
| :app | Application entrypoint, wiring, DI setup | Depends on features only; no business logic |
| :feature-* | A single user-visible feature (login, payments) | Owns its UI, presentation, and use-cases; can depend on :core and :domain |
| :domain | Business rules, use-cases | Pure Kotlin, no Android framework dependencies |
| :data | Repositories, persistence, network | Depends on domain; exposes interfaces to features |
| :core / :libs | Small, stable utilities (logger, io, image loader adapters) | Minimal dependencies; versioned and audited |
Rules to enforce:
-
Domain-first direction:
:domain<-:data<-:feature<-:app. Domain layer must not depend on Android framework classes. Use interfaces for repository boundaries so you can test:domainin isolation. -
Minimize transitive exposure: Use
implementationfor dependencies that should be private andapionly when you want to export types across modules. This keeps the transitive classpath small and compiles faster. -
Keep APIs small and versioned: Publish stable DTOs or interfaces from
:corerather than letting features share mutable data classes. -
Detect cycles early: Add a CI task that runs
./gradlew :<module>:dependenciesor a graph-checker; block merges when cycles appear.
Example settings.gradle.kts that declares modules (skeleton):
rootProject.name = "MyApp"
include(":app", ":core", ":domain", ":data", ":feature-login", ":feature-payments")
For dependency enforcement, write small Gradle tasks or unit tests (architecture tests) that assert allowed dependency edges; treat those assertions as gating rules in CI.
Gradle techniques to shrink build times and manage variants
Gradle speedups are technical hygiene: configuration avoidance, caching, and minimizing variant combinatorics.
Key levers to apply (and verify with profiling):
-
Enable the Gradle build cache and remote caches to reuse task outputs across developers and CI.
org.gradle.caching=trueis the baseline. -
Use the configuration cache carefully to avoid reconfiguring the project on each run; validate plugin compatibility before enabling.
org.gradle.configuration-cache=true. -
Prefer KSP over
kaptfor Kotlin annotation processing when libraries support it (Room, Moshi adapters, etc.); KSP runs significantly faster thankapt. -
Adopt Task Configuration Avoidance APIs (
tasks.register,Provider,configureEach) to cut configuration-phase time in multi-project builds. - Non-transitive R classes dramatically shrink resource linking and incremental R generation; AGP has non-transitive R classes enabled by default for newer projects. Profile this change in your codebase and run Android Studio's migrate tool if needed.
-
Limit flavor combinatorics during development: create a
devflavor with a narrow resource set and static build config to avoid full packaging for every build variant. The Android docs show how to limit resource configurations for faster dev builds.
Example gradle.properties (practical starting point):
# Use a reasonable heap; benchmark and tune for your CI runners
org.gradle.jvmargs=-Xmx6g -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8 -XX:+UseParallelGC -XX:MaxMetaspaceSize=1g
# Local and remote build cache
org.gradle.caching=true
# Try configuration cache after plugin validation
org.gradle.configuration-cache=true
# Non-transitive R classes (AGP 8+ default; explicit here for clarity)
android.nonTransitiveRClass=true
Use the Android Studio Build Analyzer and gradle-profiler to validate the effect of each change; measure before and after.
Small examples that save seconds:
- Replace
kaptprocessors with KSP equivalents when available. - Move shared logic and build-time constants into
:coreand useimplementationexposure to avoid recompiling dependents unnecessarily. - Avoid exponential product flavors: each flavor combination multiplies the number of tasks and outputs.
CI/CD patterns and testing strategies for multi-module apps
Design CI with module granularity and cache-awareness.
Core principles:
-
Run fast checks on PRs: static analysis, lint, and unit tests for the modules touched by the PR. Use changed-files detection to compute an affected modules set and run only those
:module:assembleand:module:testtasks. - Leverage a shared remote build cache in CI: this allows CI to reuse compiled artifacts and generated outputs produced by other CI runs or developer machines, saving wall time on repeated tasks.
- Partition heavier workloads: run a small smoke/instrumentation matrix on PRs (device emulators / a minimal device set), and run the full instrumentation suite nightly or on release branches using device farms like Firebase Test Lab.
- Use artifact and dependency caching: cache the Gradle wrapper, Gradle caches, and dependency artifacts in CI (or use the remote build cache) so each job does not re-download or recompile everything.
Example (GitHub Actions snippet — concept):
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Cache Gradle
uses: actions/cache@v4
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: ${{ runner.os }}-gradle-${{ hashFiles('**/gradle-wrapper.properties') }}
- name: Setup JDK
uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: '17'
- name: Build affected modules
run: ./gradlew :app:assembleDebug --build-cache --no-daemon
- name: Run unit tests for affected modules
run: ./gradlew :core:testDebugUnitTest :feature-login:testDebugUnitTest --build-cache --no-daemon
Measure and evolve: start with unit tests and lightweight checks on every PR and promote heavier build-and-test jobs to a scheduled nightly pipeline.
Instrumentation tests: run them less frequently on PRs, and run them against a curated device matrix in Firebase Test Lab (sharded runs for speed) for release validation. Use Test Lab for wider device coverage without managing hardware yourself.
When CI is slow despite caching: profile builds and analyze task cacheability and configuration time. Look at the Build Scan or Gradle Enterprise output to spot heavy non-cacheable tasks or eager task realization.
Practical checklist and step-by-step incremental migration plan
A phased, measurable migration wins. Use strict gates and keep a working app at every step.
Phase 0 — measure & prepare (1–2 sprints)
- Record baseline metrics: cold/clean build time, incremental build time, CI job durations, test runtimes with Build Analyzer and
gradle-profiler. - Harden CI caching (remote build cache or shared cache) and add
org.gradle.caching=truetogradle.properties. - Add a
libs.versions.tomlorbuildSrcto centralize versions and reduce duplication.
Phase 1 — extract stable core (1–3 sprints)
- Move small, stable utilities (
Resultwrappers, common UI components, extension functions) into:coreand make the API explicit. Keep:coretiny and well-tested. - Convert shared DI wiring into a single place (
:appor:coredepending on DI choice). If using Hilt, ensure@HiltAndroidApplives in theApplicationmodule and that Hilt modules are visible to theApplicationmodule.
Phase 2 — carve the first feature modules (2–4 sprints)
- Choose low-risk features (e.g., a new onboarding or a simple settings screen) and extract them into
:feature-xxxmodules that depend only on:coreand:domain. Verify they build independently. - Use
implementationto reduce API leakage. Add lint/architectural tests to assert dependency directions.
Phase 3 — stabilize Gradle & CI (1–2 sprints)
- Enable configuration cache on a branch and fix incompatibilities iteratively.
org.gradle.configuration-cache=trueonce plugins are compatible. - Add module-level CI jobs that run in parallel using your CI's matrix to speed PR validation.
Phase 4 — expand extraction and harden boundaries (ongoing)
- Extract heavier modules (data, networking). Replace direct cross-module references with well-defined interfaces. Introduce migration tasks to keep runtime behavior identical.
- Add automated checks for cycles and a module ownership chart that shows who is responsible for each module.
Phase 5 — production validation
- Deploy a canary release (A/B or staged rollouts). If using Play Feature Delivery for on-demand functionality, validate that feature modules package and serve correctly from the Play Store.
- Run a full instrumentation test suite against Firebase Test Lab on release branches.
Practical migration checklist (copyable)
- [ ] Baseline metrics captured (clean/incremental/CI).
- [ ]
org.gradle.caching=trueenabled; remote cache configured. - [ ]
libs.versions.tomlor centralized versions implemented. - [ ]
:corecreated and used by at least 2 modules. - [ ] First
:feature-*module extracted and independently buildable. - [ ] CI runs module-level tests only for changed modules.
- [ ] Instrumentation tests moved to Firebase Test Lab and sharded.
- [ ] Dependency cycles detection job added to CI.
- [ ] Non-transitive R migration planned and executed for modules where it yields gains.
Example small migration command pattern you’ll run in CI or locally:
# Build only affected modules (replace with your changed-module detection)
./gradlew :core:assembleDebug :feature-login:assembleDebug --build-cache --no-daemon
# Run unit tests for the same modules
./gradlew :core:testDebugUnitTest :feature-login:testDebugUnitTest --no-daemon --build-cache
Sources:
Optimize your build speed | Android Developers - Practical, authoritative guidance on KSP vs kapt, non-transitive R classes, configuration cache advice, and dev-flavor optimizations used to reduce build time.
Improve the Performance of Gradle Builds | Gradle User Manual - Gradle's recommendations for build cache, parallel execution, and performance best practices.
Overview of Play Feature Delivery | Android Developers - How to configure feature modules for Play delivery (dynamic feature modules) and packaging considerations.
Dependency injection with Hilt | Android Developers - Hilt setup, component lifecycles, and constraints that affect module structure and DI wiring.
Firebase Test Lab | Firebase Documentation - Guidance on running instrumentation tests at scale in CI and device matrix strategies.
Avoiding Unnecessary Task Configuration | Gradle User Guide - Task Configuration Avoidance APIs (register, named, configureEach) and migration guidance to reduce configuration-time overhead.
Profile your build | Android Studio | Android Developers - How to use Build Analyzer and gradle-profiler to measure and diagnose build bottlenecks.
It’s a non-transitive R class world | Slack Engineering blog - A real-world case study showing the build-time improvements from migrating to non-transitive R classes and practical lessons learned.
Start with measurement, extract a small :core module this sprint, and treat each module extraction as a reversible, measurable experiment.
Top comments (0)