Gradle & Modularization
Gradle KTS, version catalogs, convention plugins, KSP, build variants, configuration cache, modules
- 01
A
git rev-parsecall at the top of build.gradle.kts made even./gradlew helpslower — which phase runs that line, and where should it go?MediumThat line runs in the configuration phase, which evaluates every build script of every project on every single build — including one whose only task is
help.// build.gradle.kts // CONFIGURATION PHASE — runs on every build, including `./gradlew help` val sha = ProcessBuilder("git", "rev-parse", "--short", "HEAD") .start().inputStream.bufferedReader().readText().trim() println("Building $sha") // so does this … - 02
A colleague switched a module's dependency from
implementationtoapito fix a "cannot access class" error — what did that cost the build?Mediumapiputs that dependency on the compile classpath of every consumer, so from now on any change to its public signatures recompiles all of them, transitively.// core/network/build.gradle.kts dependencies { // Retrofit types appear in this module's public signatures, // so consumers need them to compile — and pay for every Retrofit ABI change. api(libs.retrofit) … - 03
Every module hardcodes its own dependency versions and two of them are already out of step — what does a version catalog fix, and what still needs a BOM?
MediumA version catalog gives every module one typed name for a dependency you declare yourself; a BOM constrains the versions of artifacts you never named, including the ones that arrive transitively.
// gradle/libs.versions.toml // [versions] // agp = "8.13.0" // composeBom = "2026.08.00" // retrofit = "3.0.0" // … - 04
Two flavor dimensions with two flavors each and three build types — how many variants is that, and which source sets does
freePlayDebugcompile?MediumTwelve: the dimensions multiply with each other and the result multiplies by the build types, and every variant is a full set of compile, merge, lint and package tasks.
android { namespace = "com.example.app" // order matters: `tier` outranks `store` when two source sets collide flavorDimensions += listOf("tier", "store") … - 05
QA wants a staging build with its own API URL and its own icon, installed next to production — how do you build that without a single
ifin the code?MediumThree separate mechanisms: generated constants for values the code reads, an id suffix and manifest placeholders for identity, and a per-variant source set for files.
android { namespace = "com.example.app" // compile-time: R and BuildConfig live here buildFeatures { buildConfig = true } // AGP 8+: off unless you ask defaultConfig { applicationId = "com.example.app" // runtime identity, varies per variant … - 06
The build spends four minutes in
kaptGenerateStubsbefore anything is compiled — what is kapt doing there, and what changes if you move to KSP?Mediumkapt runs Java annotation processors, and they cannot read Kotlin, so before any processing it converts every Kotlin file in the module into a Java stub and compiles those; KSP reads Kotlin symbols directly through its own resolver and skips the step entirely.
// BEFORE — kapt: Java stubs for every Kotlin file in the module plugins { id("org.jetbrains.kotlin.android") id("org.jetbrains.kotlin.kapt") } … - 07
Every edit in
buildSrcrecompiles and reconfigures the whole project — why, and what does moving the convention plugins into an included build change?MediumbuildSrcis an implicit dependency of every build script in the build, so changing it makes every compiled script class stale and forces a full reconfiguration.// settings.gradle.kts pluginManagement { includeBuild("build-logic") // a separate build, not a project dependency repositories { google(); mavenCentral(); gradlePluginPortal() } } include(":app", ":core:network", ":feature:feed") … - 08
Your codegen task reruns on every build, and now the configuration cache fails on it pointing at
project— what is wrong with the task?MediumIt declares no outputs, so Gradle can never call it up to date, and it reaches for the live
Projectat execution time, which is precisely what the configuration cache serialises away.// WRONG — no outputs, so it always reruns; touches `project` while executing tasks.register("generateConfigBad") { doLast { val out = File(project.buildDir, "generated/Config.kt") // Project at execution time out.parentFile.mkdirs() out.writeText("val API = \"" + System.getenv("API_URL") + "\"") // untracked input … - 09
A clean CI build halved once you turned the build cache on, yet a local no-op build still takes 45 seconds — which cache are you missing, and what does each one store?
HardYou are missing the configuration cache: the build cache only skips task work, and those 45 seconds are the configuration phase, which runs in full on every single invocation until it is cached.
// gradle.properties — two independent switches, plus a gate so problems cannot creep back // org.gradle.configuration-cache=true // org.gradle.caching=true // org.gradle.configuration-cache.problems=fail // settings.gradle.kts — the build cache is configured here, once, for the whole build … - 10
A one-line change in a leaf module still costs six minutes across forty modules — where is that time going, and what shortens the critical path rather than the total?
HardAlmost none of it is compiling your line: an incremental build is configuration time, plus everything your change invalidated downstream, plus a serial
:apptail that no amount of parallelism touches.// gradle.properties — change one thing at a time and re-measure // org.gradle.parallel=true // org.gradle.caching=true // org.gradle.configuration-cache=true // org.gradle.jvmargs=-Xmx6g -XX:MaxMetaspaceSize=1g // kotlin.daemon.jvmargs=-Xmx3g … - 11
Your team wants every core module split into
:core:x:apiand:core:x:implwith DI binding them in:app— what does that buy the build graph, and when is it ceremony?HardIt separates what a feature compiles against from what actually changes: features see only the tiny
apimodule's signatures, so churn insideimplnever reaches their compile classpath, and:appis the single place the two meet.// settings.gradle.kts include(":app", ":feature:checkout", ":core:cart:api", ":core:cart:impl") // core/cart/api — interfaces and models; no Retrofit, no Room, no Android framework interface CartRepository { val items: Flow<List<CartItem>> … - 12
The release build dies with "Duplicate class found in modules kotlin-stdlib and kotlin-stdlib-jdk8", yet Gradle resolved one version of each — what happened?
HardA version conflict and a duplicate class are different failures: Gradle resolves a conflict by picking the highest requested version of one module, but two different modules that happen to contain the same class are not a conflict it can see, so the collision only surfaces when D8 merges the classpath.
// Who asked for it, and why did this version win? // ./gradlew :app:dependencyInsight --configuration releaseRuntimeClasspath --dependency kotlin-stdlib // ./gradlew :app:dependencies --configuration releaseRuntimeClasspath // Two coordinates, same classes: teach Gradle they are one module dependencies { … - 13
Nobody changed a version number, yet last night's build resolved a different artifact and the APK grew three megabytes — what should the build have been doing?
HardA build that resolves from the network is only as reproducible as the repositories behind it; locking, checksum verification and a blocking dependency report are what turn "a transitive dependency moved" into a failed CI job instead of a mystery.
// build.gradle.kts (usually in a convention plugin applied to every module) dependencyLocking { lockAllConfigurations() } configurations.configureEach { resolutionStrategy { // no 1.+, no latest.release, no -SNAPSHOT … - 14
You are publishing
:core:networkas an AAR that three other apps will consume — what do you have to get right beyond applyingmaven-publish?HardThree things the default setup will not give you: a variant-aware component so consumers resolve the right artifact, consumer ProGuard rules packaged inside the AAR, and a committed ABI dump so you learn you broke someone before they do.
// core/network/build.gradle.kts plugins { id("com.android.library") id("org.jetbrains.kotlin.android") id("maven-publish") alias(libs.plugins.binaryCompatibilityValidator) … - 15
You add an on-demand
:feature:scandynamic feature module and the base app can no longer reference any of its classes — what did that do to the module graph?HardIt inverted the dependency: a dynamic feature depends on the base
:app, not the other way round, so the base can never import a feature type and everything crossing that boundary goes through an interface the base owns, an implicit intent, or reflection.// app/build.gradle.kts — the base lists its features android { dynamicFeatures += setOf(":feature:scan") } // feature/scan/build.gradle.kts — note the direction of the arrow … - 16
It compiles on every laptop and fails on CI with a Kotlin/Java target mismatch, and the remote cache never hits there — how do you make the build reproducible?
HardPin every input the build takes from the machine — the JDK, the Gradle distribution, the plugin versions and the daemon's memory — so the only thing that differs between a laptop and an agent is the source tree.
// settings.gradle.kts — let Gradle fetch a missing JDK instead of guessing plugins { id("org.gradle.toolchains.foojay-resolver-convention") version "0.8.0" } // build.gradle.kts of every module, usually via a convention plugin …