Managing the compile-time dependency graph: Android

If the project you split into forty modules still takes minutes to rebuild after a one-line change, look at the shape of the graph. We cover api versus implementation in Gradle, the api/impl module split, the things that leak unnoticed, and how to measure the gain.

You add a field to one data class in a modularized Android project and watch forty compileDebugKotlin tasks run one after another in the Gradle output. On paper the project is modular. The build still behaves like a monolith. It is a pattern we see often in large codebases: the module count grew, but nobody ever designed the direction and the kind of the arrows between those modules.

Build time is decided by the shape of the graph far more than by the number of modules. This article is about reading that shape on the Gradle side, fixing it, and proving that the fix worked. For the Xcode and SwiftPM side of the same subject, see the iOS article. The samples assume AGP 9 and Gradle 9: Kotlin support is built into AGP 9, so we do not apply org.jetbrains.kotlin.android. If you are on AGP 8, add that line back and everything else applies unchanged.

The graph is made of configurations

A Gradle module does not have one flat list of dependencies. Every line in a dependencies block goes into a configuration (implementation, api, compileOnly, ksp), and for each variant Gradle resolves two separate classpaths from them: debugCompileClasspath, which the compiler sees, and debugRuntimeClasspath, which ends up in the APK. Incremental build time is about the first one. The more modules sit on a module's compile classpath, the more changes that module is exposed to.

So when we look at a graph we look at three measures.

Depth is the longest chain from :app down to the farthest leaf. Each module in a chain waits for the previous one to compile, which makes depth the ceiling on parallel execution. Gradle's own performance guide says the same: with many dependencies between subprojects, the benefit of --parallel is minimal.

Fan-in is how many modules depend on a given module. The :core everybody depends on is, by construction, the place where every change reaches everybody.

Propagation is how many compile tasks a single change sets off. That is decided by the choice between api and implementation, and most of this article is about it.

Our running example is a hypothetical travel booking app; call it Rota. Its graph today:

:app
 ├── :feature:search ───api──▶ :feature:booking ───api──▶ :feature:checkout
 ├── :feature:profile
 └── (all of them) ───api──▶ :core
                              ├─ Retrofit services, OkHttp setup
                              ├─ Room database, DAOs (kapt)
                              ├─ theme, shared Compose components, strings.xml
                              ├─ domain models
                              └─ StringExtensions.kt (1,400 lines)

Four features and one :core. Nobody drew this graph on purpose. Each arrow arrived in a pull request, "for now".

api and implementation: what lands on the compile classpath

The Gradle documentation defines the difference plainly. A dependency declared with api is exposed transitively to consumers and appears on their compile classpath. A dependency declared with implementation does not leak into the consumer's compile classpath; it only reaches the runtime classpath.

The effect on build time comes through compile avoidance. Gradle decides whether to rerun a module's compile task by checking whether the ABI of anything on its classpath has changed. Changing a method body, a comment or a private member leaves the ABI alone, and consumers are not recompiled. Adding a public function, changing a signature or changing the fields of a public type changes the ABI. When that happens, the compile task of every module that sees the changed module on its compile classpath runs.

"Every module that sees it" is the entire difference between the two configurations. The Android documentation summarises it this way: when an implementation dependency changes its API, Gradle recompiles only that dependency and the modules that directly depend on it. When an api dependency changes its API, Gradle recompiles all modules that have access to it at compile time.

Rota's :feature:booking module currently looks like this:

// feature/booking/build.gradle.kts (before)
plugins {
    alias(libs.plugins.android.library)
    alias(libs.plugins.legacy.kapt)
}

dependencies {
    api(project(":core"))
    api(project(":feature:checkout"))   // "we need to open the payment screen"
    api(libs.retrofit)
    api(libs.androidx.room.runtime)
    kapt(libs.androidx.room.compiler)
}

Because of api(project(":feature:checkout")), :feature:search has :feature:checkout on its compile classpath without using a single type from it. When someone on the checkout team changes the signature of a public function, booking, search and :app are recompiled. The longer the chain, the further a leaf change travels, until it covers the whole app. A leaked classpath is also an invitation: someone working in search sees CheckoutViewModel in autocomplete and uses it. From that day, removing the api produces a compile error and the arrow is permanent.

The corrected version:

// feature/booking/build.gradle.kts (after)
plugins {
    alias(libs.plugins.android.library)
    alias(libs.plugins.ksp)
}

dependencies {
    api(project(":core:model"))            // Booking appears in our public signatures
    implementation(project(":core:network"))
    implementation(project(":core:database"))
    implementation(project(":core:designsystem"))
    implementation(project(":feature:checkout:api"))

    implementation(libs.retrofit)
    implementation(libs.androidx.room.runtime)
    ksp(libs.androidx.room.compiler)
}

The rule follows from Gradle's own definition. If a dependency's types appear in your module's public signatures (a public function parameter, a return type, a supertype, a public field), it is an api dependency, because a consumer cannot compile against you without that type. If it is used only inside function bodies and in internal or private code, it is implementation.

The default is implementation. Every api line should sit next to its justification.

One caveat. On the Kotlin side, a compile task running does not mean the module is compiled from scratch. The Kotlin Gradle plugin uses classpath snapshots to work out which classes are affected and compiles only those. The task still executes, snapshots are compared, and annotation processing steps may run again because their inputs changed. Across forty modules those small costs add up to a wait you can feel. How large it is in your project is something to measure, as described below, and not to guess.

Splitting features into api and impl

Moving to implementation shortens propagation by one hop, but as long as features depend on each other directly the chain is still there. Booking usually depends on checkout for a single reason: to open that screen. Pulling checkout's ViewModel, repository and Room entities onto the compile classpath is an expensive way to do it.

The fix is to put the contract other modules need into a small :api module and everything else into an :impl module:

:feature:checkout:api    route type, interfaces the feature exposes (small, rarely changes)
:feature:checkout:impl   screens, ViewModels, repository, DI modules

With the type-safe routes in Navigation Compose (Navigation 2.8.0 and later), the :api module is a few lines:

// feature/checkout/api/src/main/kotlin/com/rota/checkout/api/CheckoutRoute.kt
package com.rota.checkout.api

import kotlinx.serialization.Serializable

@Serializable
data class CheckoutRoute(val bookingId: String)

:impl offers the function that registers that route in the graph:

// feature/checkout/impl/src/main/kotlin/com/rota/checkout/impl/CheckoutNavigation.kt
package com.rota.checkout.impl

import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable
import androidx.navigation.toRoute
import com.rota.checkout.api.CheckoutRoute

fun NavGraphBuilder.checkoutScreen(onPaid: () -> Unit) {
    composable<CheckoutRoute> { entry ->
        val route: CheckoutRoute = entry.toRoute()
        CheckoutScreen(bookingId = route.bookingId, onPaid = onPaid)
    }
}

The wiring happens in :app. It is the only place that sees every :impl module, and as the composition root that is exactly its job:

// app/src/main/kotlin/com/rota/RotaNavHost.kt
NavHost(navController, startDestination = SearchRoute) {
    searchScreen(onFlightSelected = { id -> navController.navigate(BookingRoute(id)) })
    bookingScreen(onProceed = { bookingId -> navController.navigate(CheckoutRoute(bookingId)) })
    checkoutScreen(onPaid = { navController.popBackStack(SearchRoute, inclusive = false) })
}

In this arrangement :feature:booking:impl hands the transition out as a lambda, so it may not need :feature:checkout:api at all. If you want booking to decide on the transition internally, it depends on :feature:checkout:api only, with implementation. Android's modularization guide recommends the same approach: features do not depend on each other, a module such as :app mediates between them, and navigation arguments carry ids instead of objects.

The gain: whatever the checkout team changes inside :impl, ABI changes included, the modules that recompile are :feature:checkout:impl and :app. Booking has a one-file :api on its classpath and nothing else from checkout.

We do not split every feature. The split doubles the module count and every module has a configuration cost. Split the features other features depend on and leave leaf features as a single module. We work through that cost in the cost of modularization.

Four things that leak without you noticing

Even when your build.gradle.kts files look clean, there are other channels that cross module boundaries.

R classes

With transitive R classes, each module's R class contains references to the resources of every module it depends on. Add one string to :core:designsystem and the R class of every dependent module changes. With non-transitive R, each module's R class contains only its own resources. That has been the default since AGP 8.0.0. An older project may have been migrated with android.nonTransitiveRClass=false in gradle.properties and a promise to look at it later; if that line is still there, your graph has an api chain you cannot see. Once you remove it, resources from other modules have to be referenced by their fully qualified R class (com.rota.core.designsystem.R.string.retry). The "Migrate to Non-Transitive R Classes" action in Android Studio's Refactor menu does the conversion.

Annotation processing

To run Java annotation processors, kapt generates Java stubs from your Kotlin sources. The Android documentation calls kapt significantly slower than KSP and recommends migrating every processor that supports it. With built-in Kotlin in AGP 9 the org.jetbrains.kotlin.kapt plugin no longer works and is replaced by com.android.legacy-kapt, a name that tells you where things are heading. What matters for the graph is that the processor's input is the module's compile classpath: the more that classpath is inflated by api chains, the more often stub generation and processing are triggered. Also note that a module with even one remaining kapt processor keeps generating stubs, so the gain arrives when the last kapt line in the module is gone.

Resources

implementation narrows the compile classpath and nothing else. Resources are merged into a single namespace at packaging time: if the same resource name exists in the app and in a library, the app's is used, and when two libraries collide the one listed first in the dependencies block wins. Two features that each define a string called title will have one silently showing the other's text. Follow the Android documentation and use a prefix unique to the module (booking_title, checkout_title).

Hilt aggregation

Hilt collects @InstallIn modules and @AndroidEntryPoint classes in the module that compiles your Application. For that, the module needs every Hilt-using module among its transitive dependencies. That requirement used to push teams into turning implementation into api, and many graphs went bad for precisely that reason. Today the Hilt Gradle plugin does the aggregation in a dedicated Gradle task (enableAggregatingTask, enabled by default), so depending on your :impl modules with implementation is enough. The documentation marks the older enableExperimentalClasspathAggregation flag as deprecated; if it is still in your build files, remove it.

Aggregation shows up in the graph in two ways. First, :app has to depend on every feature's :impl module, which is the right thing for a composition root anyway. Second, every @InstallIn module that reaches the classpath joins the app's DI graph without anyone referencing it. Add a test-helper module with implementation by mistake and its fake bindings come along. We cover that family of mistakes in DI anti-patterns on Android.

Build-level levers: what they fix and what they leave alone

Three lines in gradle.properties should be on in most projects:

org.gradle.configuration-cache=true
org.gradle.caching=true
org.gradle.parallel=true

All three are worth having and none of them changes the graph.

The configuration cache stores the result of the configuration phase (the task graph and each task's configured state) and skips that phase entirely when the build scripts have not changed. It has been the preferred mode of execution since Gradle 9. Configuration time grows with module count, so this is what pays for most of the extra modules an api/impl split introduces. It has no say in which tasks run.

The build cache restores the output of a task whose inputs match an earlier build, from CI or from another branch. The compile classpath is part of those inputs. If the ABI of :core changed, the cache key of every module depending on it changed too, and the cache misses. Wide fan-in lowers your hit rate.

Parallel execution runs tasks from different projects at the same time. It cannot parallelise modules that are queued behind each other in a chain. One of the gains from flattening the graph is that it gives parallelism room to work.

Convention plugins and version catalogs belong in the same category. They gather the repetition of forty build.gradle.kts files in one place and keep versions together in gradle/libs.versions.toml. Managing an api/impl split without them is miserable, so use them. But if a convention plugin called rota.android.feature contains api(project(":core")), you have distributed the mistake to forty modules in one line.

Build settings make the existing graph run faster. Only dependencies blocks change the graph.

Seeing the graph and putting rules on it

For a first look, Gradle's own report is enough:

# Everything :app sees at compile time
./gradlew :app:dependencies --configuration debugCompileClasspath

# By which path did a module get onto the compile classpath?
./gradlew :feature:search:dependencyInsight \
    --dependency :feature:checkout --configuration debugCompileClasspath

Comparing the output for debugCompileClasspath with debugRuntimeClasspath is instructive. In a project that uses implementation properly, a feature's compile classpath is clearly smaller than its runtime classpath. If the two are nearly identical, everything is leaking through api. A build scan (./gradlew assembleDebug --scan) gives you the same information in searchable form, and its timeline view shows which tasks actually ran in parallel. Unless you run your own Develocity server, the scan is uploaded to Gradle's hosted service, so make sure that fits your company's policy.

What you saw by eye is forgotten a week later. What lasts is a rule written into the build. We use two tools.

The Dependency Analysis Gradle Plugin reports, per module, unused dependencies, dependencies used transitively but not declared, and wrong configurations (implementation where it should be api, or the reverse). At the time of writing the current version is 3.19.2:

// settings.gradle.kts
plugins {
    id("com.autonomousapps.build-health") version "3.19.2"
}
// build.gradle.kts (root)
dependencyAnalysis {
    issues {
        all {
            onIncorrectConfiguration { severity("fail") }
            onUnusedDependencies { severity("fail") }
            onUsedTransitiveDependencies { severity("warn") }
        }
    }
}

./gradlew buildHealth produces advice for the whole project and ./gradlew :feature:booking:projectHealth for one module. You can ask for the reasoning behind a piece of advice with ./gradlew :feature:booking:reason --id :core:network. The fixDependencies task applies the advice to your build files; in a large project we suggest doing that module by module and compiling at each step. For the finer points of setup (root script or settings plugin), read the wiki for the version you use, because the DSL has changed between versions.

Module Graph Assert puts rules on the shape of the graph: allowed arrows, forbidden arrows and a maximum height. Its README only shows Groovy; in the Kotlin DSL, allowed and restricted are Array<String>, so it reads like this:

// app/build.gradle.kts
plugins {
    id("com.jraska.module.graph.assertion") version "2.9.1"
}

moduleGraphAssert {
    maxHeight = 4
    allowed = arrayOf(
        ":app -> .*",
        ":feature:.*:impl -> :feature:.*:api",
        ":feature:.* -> :core:.*",
        ":core:.* -> :core:model",
    )
    restricted = arrayOf(
        ":feature:.*:api -X> :core:(network|database)",
    )
}

The assertModuleGraph task is attached to check, so it already runs in CI. Once you define allowed, any arrow that matches none of the patterns breaks the build. After writing the rules, add a deliberately wrong dependency and watch the task fail; regular expressions tend to allow more than you intended. If you also want to police architecture rules at class level, enforcing architecture rules on Android picks up from here.

Measuring: touch a leaf, touch core

You cannot measure a graph change with a clean build, because a clean build compiles everything regardless. What you need to measure is what a developer does a hundred times a day: change a file and build again.

For a one-off look, ./gradlew :app:assembleDebug --profile is enough. The report is written to build/reports/profile in the root project and lists which tasks ran and which stayed UP-TO-DATE. Build Analyzer in Android Studio answers the same question from inside the IDE.

For repeatable numbers we use gradle-profiler (brew install gradle-profiler, or SDKMAN). A scenario file applies an automatic change to a file you name on every iteration:

# rota.scenarios
leaf_non_abi {
    title = "Leaf: function body changed"
    tasks = [":app:assembleDebug"]
    apply-non-abi-change-to = "feature/checkout/impl/src/main/kotlin/com/rota/checkout/impl/CheckoutViewModel.kt"
}

leaf_abi {
    title = "Leaf: public function added"
    tasks = [":app:assembleDebug"]
    apply-abi-change-to = "feature/checkout/impl/src/main/kotlin/com/rota/checkout/impl/CheckoutViewModel.kt"
}

core_abi {
    title = "Core: public function added"
    tasks = [":app:assembleDebug"]
    apply-abi-change-to = "core/model/src/main/kotlin/com/rota/model/Booking.kt"
}

core_resource {
    title = "Core: string added"
    tasks = [":app:assembleDebug"]
    apply-android-resource-change-to = "core/designsystem/src/main/res/values/strings.xml"
}
gradle-profiler --benchmark --project-dir . --scenario-file rota.scenarios \
    --warmups 4 --iterations 10

apply-abi-change-to adds a new public method to the class on each iteration and removes the previous one; apply-non-abi-change-to changes the body of a public method. Both work on Java and Kotlin sources. Results go to profile-out/benchmark.html and benchmark.csv, with mean, median and standard deviation per scenario.

The ratios between those four scenarios are an X-ray of your graph. If leaf_abi takes about as long as core_abi, your leaf has stopped being a leaf and is leaking through an api somewhere. If even leaf_non_abi is slow, the problem comes before the graph: it is inside that module (kapt, one very large module) or in the packaging steps of :app. If core_resource is close to core_abi, look at transitive R.

Fixing Rota

Here is the order in which we would fix the bad graph above. The order matters, because the first steps are cheap and bring most of the gain.

  1. Run the four profiler scenarios on the current main and keep the profile-out directory. That is your baseline.
  2. Run buildHealth and turn every unnecessary api into implementation. The places that break are modules that were leaning on a transitive dependency without knowing; declare the missing dependency explicitly there. This step moves no code between modules.
  3. Split :core by how often things change: :core:model, :core:network, :core:database, :core:designsystem. The goal is for each feature to put only the part it uses on its classpath. Before creating a :core:common for the 1,400-line StringExtensions.kt, check how much of it is still called.
  4. Split the features that other features depend on (booking and checkout here) into :api and :impl, and move the navigation wiring into :app. No project(...) line should point from one feature's implementation to another's.
  5. Move from kapt to KSP, and remove android.nonTransitiveRClass=false if it is there.
  6. Add the Module Graph Assert rules and buildHealth to CI. Without this step the graph is back to its old shape within six months.

The resulting shape:

:app ──▶ :feature:search            ──▶ :core:model, :core:network, :core:designsystem
     ──▶ :feature:profile           ──▶ :core:model, :core:database, :core:designsystem
     ──▶ :feature:booking:impl      ──▶ :feature:booking:api, :feature:checkout:api, :core:*
     ──▶ :feature:checkout:impl     ──▶ :feature:checkout:api, :core:*
     ──▶ :feature:booking:api       ──▶ :core:model
     ──▶ :feature:checkout:api

To confirm, run the same scenarios again and put the two benchmark.csv files side by side. The change we expect: leaf_abi moves close to leaf_non_abi, because an ABI change in :feature:checkout:impl now touches only that module and :app. core_abi gets shorter but does not go away; :core:model is still what everybody depends on, and that is fine. The goal there is to keep that module small and rarely changing. There is a second piece of evidence that does not depend on timing: after the leaf_abi scenario, count the compile tasks that executed in the --profile report. The number should match what the graph predicts.

There are projects where this work does not pay. In a project with six or seven modules whose incremental build is already measured in seconds, an api/impl split only adds ceremony. Step two (cleaning up unnecessary api) is enough there. The split earns its keep where several teams depend on the same features and the core_abi scenario has turned into a coffee break.

Checklist

  • The leaf_non_abi, leaf_abi, core_abi and core_resource scenarios were measured with gradle-profiler and the results kept.
  • Every api line has a justification: the type appears in the module's public signatures.
  • buildHealth is clean, or the remaining exceptions were left on purpose.
  • No feature-to-feature project(...) dependency; transitions are wired in :app or through :api modules.
  • :core is no longer a single module; the part with the highest fan-in (:core:model) is small and rarely changes.
  • android.nonTransitiveRClass=false and enableExperimentalClasspathAggregation are gone from the build files.
  • No module uses kapt, or there is a list of the remaining ones and a migration plan.
  • Resource names start with a module prefix.
  • Configuration cache, build cache and parallel execution are on.
  • Convention plugins do not hand out api(project(...)).
  • assertModuleGraph runs in CI and was shown to fail on a deliberately wrong dependency.
  • The scenarios were rerun after the refactor, and the number of compile tasks executed after leaf_abi matches expectations.
Level 11 completeYour progress is kept in this browser.
Up next: Making the compiler enforce your architecture dependency rules: iOS

Need support on this?

Let's review together whether these controls are set up correctly in your app. We're here with security audits and advisory.

Comments and questions

Disagree with something, or stuck on this in your own project? Write to us and let's talk.