Gradle & Modularization
Irbisa · cheatsheetSeptember 13, 2026

Gradle & Modularization

Gradle KTS, version catalogs, convention plugins, KSP, build variants, configuration cache, modules

Senior Developer16 itemscompressed for a skim
  1. 01

    A git rev-parse call at the top of build.gradle.kts made even ./gradlew help slower — which phase runs that line, and where should it go?

    Medium

    That 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
    …
  2. 02

    A colleague switched a module's dependency from implementation to api to fix a "cannot access class" error — what did that cost the build?

    Medium

    api puts 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)
    …
  3. 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?

    Medium

    A 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"
    //
    …
  4. 04

    Two flavor dimensions with two flavors each and three build types — how many variants is that, and which source sets does freePlayDebug compile?

    Medium

    Twelve: 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")
    …
  5. 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 if in the code?

    Medium

    Three 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
    …
  6. 06

    The build spends four minutes in kaptGenerateStubs before anything is compiled — what is kapt doing there, and what changes if you move to KSP?

    Medium

    kapt 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")
    }
    …
  7. 07

    Every edit in buildSrc recompiles and reconfigures the whole project — why, and what does moving the convention plugins into an included build change?

    Medium

    buildSrc is 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")
    …
  8. 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?

    Medium

    It declares no outputs, so Gradle can never call it up to date, and it reaches for the live Project at 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
    …
  9. 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?

    Hard

    You 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. 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?

    Hard

    Almost none of it is compiling your line: an incremental build is configuration time, plus everything your change invalidated downstream, plus a serial :app tail 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. 11

    Your team wants every core module split into :core:x:api and :core:x:impl with DI binding them in :app — what does that buy the build graph, and when is it ceremony?

    Hard

    It separates what a feature compiles against from what actually changes: features see only the tiny api module's signatures, so churn inside impl never reaches their compile classpath, and :app is 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. 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?

    Hard

    A 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. 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?

    Hard

    A 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. 14

    You are publishing :core:network as an AAR that three other apps will consume — what do you have to get right beyond applying maven-publish?

    Hard

    Three 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. 15

    You add an on-demand :feature:scan dynamic feature module and the base app can no longer reference any of its classes — what did that do to the module graph?

    Hard

    It 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. 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?

    Hard

    Pin 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
    …