Android Build System · Complete Reference

Gradle
Build
System

Phases · Caching · Multi-module · Performance

Gradle is not just a build tool — it is a programmable execution engine. Understanding how it works transforms your build from a black box into a precisely tuned, fast, reproducible machine.

Build Phases Caching Multi-module Convention Plugins Version Catalogs Config Cache Parallel Builds
01 · Foundation

What is Gradle?

Gradle is a general-purpose build automation tool built on a directed acyclic graph (DAG) of tasks. It is not specific to Android — it builds Java, Kotlin, C++, Python, and more. The Android Gradle Plugin (AGP) is a set of Gradle plugins and tasks that teach Gradle how to compile, package, test, and sign Android applications.

At its core, Gradle has three phases it always runs through in order: Initialization, Configuration, and Execution. Every performance problem in a slow build can be traced to one of these phases doing too much work. Understanding the boundary between them is the single most important Gradle concept.

Gradle itself
The Build Engine
Manages the task graph, executes tasks in dependency order, handles incremental builds, caches outputs. Language-agnostic. Knows nothing about Android by default.
Android Gradle Plugin
The Android Layer
Registers tasks like compileDebugKotlin, processDebugResources, packageDebugApk. Wires them into the Gradle task graph. AGP version must be compatible with your Gradle version.
Build scripts
build.gradle.kts
Kotlin (or Groovy) scripts that configure plugins, declare dependencies, and register tasks. They run during the Configuration phase — not during execution. Common mistake: doing work in a script that should be in a task.
settings.gradle.kts
Project Structure
Defines which modules participate in the build, plugin management (repository sources for plugins), dependency resolution management. Evaluated during the Initialization phase.
02 · Core Concept

Build Phases

Every Gradle build runs through exactly three phases in strict order. Code that runs in the wrong phase is the most common source of both build errors and performance problems. Getting this right is foundational.

01
Initialization Phase

Gradle reads settings.gradle.kts and determines which projects participate in the build. For a single-project build this is trivial. For a 50-module project, Gradle discovers and instantiates all project objects. Each module's buildSrc is also compiled here. Nothing from your build scripts runs yet.

~50–500ms
02
Configuration Phase

Every build.gradle.kts file in every participating module is executed — even for tasks you are not running. This builds the complete task dependency graph. This is the most commonly misunderstood phase. Any I/O, network calls, or slow logic in a build script slows down every single build. Use the Configuration Cache to skip this phase on subsequent builds.

0.5–10s typical
03
Execution Phase

Only the tasks required by the requested task and its dependencies are executed, in dependency order. Tasks are skipped if: their inputs and outputs haven't changed (incremental build), their output is available from cache (build cache hit), or they are marked UP-TO-DATE. This is where the actual compilation, resource processing, and packaging happens.

10s–10min

Critical rule: Build scripts run during Configuration, not Execution. Never do I/O, file reading, network calls, or expensive computations directly in a build.gradle.kts script. Put that logic inside a tasks.register { doLast { ... } } block so it runs during Execution — and only when needed.

Phase boundary — wrong vs right
// ✗ WRONG — this runs during Configuration on EVERY build, even `gradle tasks`
val version = File("version.txt").readText().trim()  // IO in config phase
val gitHash = Runtime.exec("git rev-parse --short HEAD")  // process in config phase

// ✓ CORRECT — runs during Execution, only when this task is needed
tasks.register("generateBuildConfig") {
    doLast {
        val version = File("version.txt").readText().trim()
        val gitHash = ProcessBuilder("git", "rev-parse", "--short", "HEAD")
            .start().inputStream.bufferedReader().readLine()
        println("Version: $version, Hash: $gitHash")
    }
}

// ✓ ALSO CORRECT — lazy provider, evaluated only when needed
val versionProvider = providers.fileContents(layout.projectDirectory.file("version.txt"))
    .asText.map { it.trim() }

Task lifecycle within Execution

Every Gradle task has three action blocks: doFirst { } runs before the task's main action, the main action (defined by the task type), and doLast { } runs after. Tasks also declare their inputs and outputs — Gradle uses these to determine UP-TO-DATE status and to cache outputs.

Task inputs/outputs — incremental builds
@CacheableTask
abstract class GenerateVersionTask : DefaultTask() {

    @get:InputFile
    @get:PathSensitive(PathSensitivity.RELATIVE)
    abstract val versionFile: RegularFileProperty

    @get:Input
    abstract val buildNumber: Property<Int>

    @get:OutputFile
    abstract val outputFile: RegularFileProperty

    @TaskAction
    fun generate() {
        val version = versionFile.get().asFile.readText().trim()
        outputFile.get().asFile.writeText("$version.${buildNumber.get()}")
    }
    // Gradle tracks inputs: if versionFile and buildNumber unchanged → UP-TO-DATE
    // @CacheableTask: outputs cached remotely → other machines skip this task
}
03 · Build Scripts

Groovy vs Kotlin DSL

Gradle historically used a Groovy-based DSL (.gradle files). Modern Android projects use the Kotlin DSL (.gradle.kts files). The switch matters for two reasons: type safety catches configuration errors at compile time, and IDE tooling (auto-complete, navigation, refactoring) works properly.

Groovy DSL — legacy
build.gradle
Dynamic typing — typos in property names silently fail at runtime. No IDE auto-complete for Gradle APIs. Closure syntax is unfamiliar to Kotlin developers. Still works, but avoid for new projects.
Kotlin DSL — recommended
build.gradle.kts
Statically typed — Gradle APIs and extension properties are fully typed. Full IDE support: auto-complete, go-to-definition, refactoring. Errors caught at sync time, not at build time. This is what Google recommends for all new Android projects.
Equivalent configurations — Groovy vs Kotlin DSL
// ── Groovy (build.gradle) ──────────────────────────────
android {
    compileSdkVersion 34
    defaultConfig {
        applicationId "com.example.app"
        minSdkVersion 24
        targetSdkVersion 34
        versionCode 1
        versionName "1.0"
    }
    buildTypes {
        release {
            minifyEnabled true
            proguardFiles getDefaultProguardFile('proguard-android-optimize.txt')
        }
    }
}

// ── Kotlin DSL (build.gradle.kts) ──────────────────────
android {
    compileSdk = 34
    defaultConfig {
        applicationId = "com.example.app"
        minSdk = 24
        targetSdk = 34
        versionCode = 1
        versionName = "1.0"
    }
    buildTypes {
        release {
            isMinifyEnabled = true
            proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"))
        }
    }
}
04 · Architecture

Multi-module Projects

A multi-module project divides your app into separate Gradle modules — each compiled independently, each with its own dependency graph. This is the single most impactful architectural decision for build performance in large Android projects.

The key insight: Gradle can build independent modules in parallel. If :feature:home and :feature:settings don't depend on each other, they compile simultaneously. A well-modularized 50-module project can be dramatically faster than a monolith — even if the total code is the same.

TYPICAL MULTI-MODULE DEPENDENCY GRAPH
:app
:feature:home
:feature:settings
:feature:profile
:feature:checkout
:core:ui
:core:network
:core:data
:core:testing
:core:common
:core:model
Application module
Feature modules (parallel compile)
Core modules (shared infra)
Foundation modules (no deps on app code)
settings.gradle.kts — declaring modules
pluginManagement {
    repositories {
        google()
        mavenCentral()
        gradlePluginPortal()
    }
}
dependencyResolutionManagement {
    repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
    repositories { google(); mavenCentral() }
}

include(":app")
include(":feature:home")
include(":feature:settings")
include(":feature:profile")
include(":core:ui")
include(":core:network")
include(":core:data")
include(":core:common")
include(":core:model")
include(":core:testing")

Module types matter: Use com.android.library for modules with Android resources/code, com.android.application only for the final APK module, and plain org.jetbrains.kotlin.jvm for pure Kotlin modules with no Android dependencies. Pure Kotlin modules compile faster — no AAPT2, no manifest merging, no resource compilation.

05 · Code Sharing

Sharing Code Across Modules

The challenge in multi-module projects is managing what is shared and how. There are three primary mechanisms, each with different trade-offs around coupling, build performance, and IDE support.

Three sharing mechanisms
// ── 1. Module dependency (recommended) ──────────────────────────
// In :feature:home/build.gradle.kts
dependencies {
    implementation(project(":core:ui"))        // compile-time + runtime
    implementation(project(":core:data"))
    testImplementation(project(":core:testing"))
    // api() vs implementation():
    // implementation = dependency NOT exposed to consumers (preferred)
    // api = dependency IS exposed — creates a transitive dependency
}

// ── 2. buildSrc — shared build logic ────────────────────────────
// buildSrc/src/main/kotlin/Dependencies.kt
object Versions {
    const val KOTLIN = "1.9.0"
    const val COMPOSE = "1.5.0"
}
// Automatically available in all build scripts — but slow to change
// Changing ANY file in buildSrc invalidates ALL configuration caches

// ── 3. Included build (better than buildSrc) ─────────────────────
// settings.gradle.kts
includeBuild("build-logic")  // dedicated build for convention plugins

api() vs implementation() — the critical distinction

This is one of the most impactful decisions in multi-module builds. Using api() when you should use implementation() creates unnecessary recompilation cascades. When module A uses api(project(":core:model")), any module that depends on A also transitively sees :core:model — and must recompile when :core:model changes.

api() — use sparingly
Leaked dependency
The dependency is visible to consumers of your module. Types from it can appear in your public API. Changes to the dependency force recompilation of all downstream modules. Use only when your module's public interface includes types from the dependency.
implementation() — default
Encapsulated dependency
The dependency is private to your module. Consumers cannot access it. Changes to this dependency do not force recompilation of downstream modules that depend on you. This is why implementation() is the default and the correct choice for most dependencies.
api vs implementation — recompilation impact
// :core:network module
dependencies {
    api(libs.retrofit)         // ✗ Retrofit types leak into all consumers of :core:network
                               // Changing Retrofit version → recompile :feature:home, :feature:settings, ALL consumers
    implementation(libs.retrofit)  // ✓ Retrofit is an internal detail
                                   // Changing Retrofit → only :core:network recompiles
}

// Only use api() when the type IS your public API:
dependencies {
    api(project(":core:model"))  // ✓ Your public functions return/accept model types
    implementation(project(":core:network"))  // ✓ Internal network calls — not exposed
}
06 · DRY Build Config

Convention Plugins

In a 20-module project, you'll find yourself copying the same android { compileSdk = 34; kotlinOptions { jvmTarget = "17" } } block into every build file. Convention plugins solve this: they are precompiled Gradle plugins that encode your project's build conventions, applied to modules that follow that convention.

Convention plugins live in an included build called build-logic. Unlike buildSrc, changes to build-logic plugins only invalidate modules that apply them — not the entire project configuration cache.

build-logic setup
// Project structure:
// ├── build-logic/
// │   ├── convention/
// │   │   ├── build.gradle.kts
// │   │   └── src/main/kotlin/
// │   │       ├── AndroidApplicationConventionPlugin.kt
// │   │       ├── AndroidLibraryConventionPlugin.kt
// │   │       ├── AndroidFeatureConventionPlugin.kt
// │   │       └── AndroidComposeConventionPlugin.kt
// │   └── settings.gradle.kts
// └── settings.gradle.kts  ← includeBuild("build-logic")

// build-logic/convention/build.gradle.kts
plugins {
    `kotlin-dsl`
}
dependencies {
    compileOnly(libs.android.gradlePlugin)
    compileOnly(libs.kotlin.gradlePlugin)
}
AndroidLibraryConventionPlugin.kt — encoding conventions
class AndroidLibraryConventionPlugin : Plugin<Project> {
    override fun apply(target: Project) {
        with(target) {
            with(pluginManager) {
                apply("com.android.library")
                apply("org.jetbrains.kotlin.android")
            }
            extensions.configure<LibraryExtension> {
                configureKotlinAndroid(this)  // shared extension function
                defaultConfig.targetSdk = 34
                // No need to repeat compileSdk, jvmTarget in every module
            }
            val libs = extensions.getByType<VersionCatalogsExtension>().named("libs")
            dependencies {
                add("implementation", libs.findLibrary("timber").get())
                add("testImplementation", libs.findLibrary("junit").get())
            }
        }
    }
}

// shared extension function used by all convention plugins:
fun Project.configureKotlinAndroid(commonExtension: CommonExtension<*, *, *, *, *, *>) {
    commonExtension.apply {
        compileSdk = 34
        defaultConfig { minSdk = 24 }
        compileOptions {
            sourceCompatibility = JavaVersion.VERSION_17
            targetCompatibility = JavaVersion.VERSION_17
        }
        kotlinOptions { jvmTarget = "17" }
    }
}
Feature module build.gradle.kts — clean and minimal
// Instead of 50 lines of repeated configuration:
plugins {
    alias(libs.plugins.nowinandroid.android.feature)  // convention plugin!
    alias(libs.plugins.nowinandroid.android.library.compose)
}

android {
    namespace = "com.example.feature.home"
}

dependencies {
    implementation(project(":core:data"))
    implementation(project(":core:ui"))
    // compileSdk, minSdk, jvmTarget, test deps — all from convention plugin
}
07 · Performance

Build Caching

Gradle has three caching layers. Understanding all three — and the difference between them — is essential for fast builds. Each layer operates on different inputs and has different scope and lifetime.

Incremental Build
Task-level, same machine
UP-TO-DATE check per task
Inputs unchanged = skip task
No output storage needed
Works on every build
Default — always enabled
Local Build Cache
Output cache, same machine
Stores task outputs by hash
Survives clean builds
Branch switching: restore instantly
Stored in ~/.gradle/caches/
Enable: org.gradle.caching=true
Remote Build Cache
Output cache, team-wide
Shared across all developers
CI populates, devs consume
New checkout = first build fast
Requires Gradle Enterprise or S3
Best ROI for team of 5+
Configuration Cache
Config phase cache
Caches entire task graph
Skips Configuration phase
Save 0.5–10s per build
Requires plugin compatibility
Enable: org.gradle.configuration-cache=true

Cache key = hash of all task inputs. For a task to be cache-hit on another machine, its input hash must match exactly. Inputs include: source files, dependencies, compiler flags, JVM version, Gradle version, AGP version. Non-deterministic inputs (timestamps, absolute paths) break cacheability. Always use @PathSensitive and avoid absolute paths in task inputs.

gradle.properties — enabling caches
# gradle.properties
org.gradle.caching=true                   # local build cache
org.gradle.configuration-cache=true       # config cache (Gradle 8+)
org.gradle.parallel=true                  # parallel module execution
org.gradle.daemon=true                    # keep JVM alive between builds
org.gradle.jvmargs=-Xmx4g -XX:+UseG1GC   # Gradle daemon heap

# Remote cache (Gradle Enterprise / Develocity)
com.gradle.develocity.url=https://ge.mycompany.com
com.gradle.develocity.build-scan.upload-in-background=true
08 · Config Cache

Configuration Cache

The Configuration Cache is one of the most impactful performance improvements in recent Gradle history. It serializes the entire task graph after the Configuration phase. On subsequent builds where configuration inputs (build scripts, settings) haven't changed, Gradle restores the task graph from cache — completely skipping the Configuration phase.

0ms
Config phase on cache hit
80%
of builds are cache hits
5–15s
saved per incremental build
AGP 8+
fully compatible
Configuration cache compatibility — common violations
// ✗ VIOLATION — accessing project at execution time
tasks.register("myTask") {
    doLast {
        val version = project.version  // project not accessible at execution time
    }
}

// ✓ CORRECT — capture at configuration time
tasks.register("myTask") {
    val version = project.version.toString()  // captured at config time, serialized
    doLast {
        println(version)  // uses captured value at execution time
    }
}

// ✗ VIOLATION — using System.getenv() in a task action directly
tasks.register("badTask") {
    doLast { println(System.getenv("CI")) }  // not serializable
}

// ✓ CORRECT — use Gradle providers
tasks.register("goodTask") {
    val ci = providers.environmentVariable("CI")  // lazy, serializable provider
    doLast { println(ci.orNull) }
}
09 · Parallelism

Parallel Execution

With org.gradle.parallel=true, Gradle can execute independent tasks from different modules simultaneously. The number of parallel workers defaults to the number of CPU cores minus 1. This is the biggest single build-time lever for multi-module projects.

Typical parallel build timeline (8 modules, 8 cores)

Worker threads (each row = one parallel worker)
Worker 1
:core:model
:feature:home
Worker 2
:core:common
:feature:settings
Worker 3
:core:network
:feature:profile
Worker 4
:core:data
:app
0s10s20s30s (total)

Maximizing parallelism: The more independent your module graph is, the more parallelism is possible. Avoid unnecessary dependencies between modules. Keep your dependency graph as wide (many modules at the same level) as possible, rather than deep (long chains). Each extra layer of depth adds to the critical path length.

10 · Optimization

Performance Tips

A comprehensive checklist of Gradle performance optimizations — ordered by impact, with the settings that deliver the biggest improvements first.

OptimizationImpactHowNotes
Configuration CacheHIGHorg.gradle.configuration-cache=trueSkips entire Config phase on subsequent builds. 5–15s saved per build.
Parallel executionHIGHorg.gradle.parallel=trueLinear improvement with number of independent modules. Biggest gain in multi-module projects.
Local build cacheHIGHorg.gradle.caching=trueSurvives clean builds. Switching branches restores from cache instantly.
Daemon warm JVMMEDIUMorg.gradle.daemon=true (default)Keeps the Gradle JVM alive. First build after reboot is slow; subsequent builds skip JVM startup.
Increase daemon heapMEDIUMorg.gradle.jvmargs=-Xmx4gPrevents GC pressure in Gradle daemon. 4GB is usually sufficient. Don't exceed available RAM.
Use implementation not apiHIGHCode disciplinePrevents unnecessary recompilation cascade when library changes. Reduces the blast radius of changes.
Modularize aggressivelyHIGHArchitectureMore modules = more parallelism. Target: no module takes more than 30s to compile independently.
Kotlin incremental compilationMEDIUMkotlin.incremental=true (default)Only recompiles files that changed and files that depend on them. Works best with module boundaries.
kapt → KSP migrationHIGHReplace kapt with kspKSP is 2× faster than KAPT for annotation processing. Migrate Room, Hilt, Moshi to their KSP versions.
Non-transitive R classesMEDIUMandroid.nonTransitiveRClass=trueEach module only sees its own R class. Reduces the R class size and recompilation scope.
Avoid dynamic versionsMEDIUMNever use 1.0.+ in depsDynamic versions require network check on every build. Always pin exact versions.
Remote build cacheHIGHGradle Enterprise / DevelocityFirst build on a new machine hits CI cache. Developer never compiles what CI already compiled.
gradle.properties — complete optimized configuration
FAST
# Execution
org.gradle.daemon=true
org.gradle.parallel=true
org.gradle.caching=true
org.gradle.configuration-cache=true
org.gradle.configuration-cache-problems=warn  # warn instead of fail during migration

# JVM tuning — adjust maxHeap to 50% of your RAM
org.gradle.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=512m -XX:+UseG1GC \
    -XX:SoftRefLRUPolicyMSPerMB=0 -XX:+HeapDumpOnOutOfMemoryError

# Kotlin
kotlin.incremental=true
kotlin.incremental.useClasspathSnapshot=true  # ABI-based incremental compilation
kotlin.build.report.output=file

# Android
android.nonTransitiveRClass=true
android.enableJetifier=false  # if you've migrated fully to AndroidX
android.defaults.buildfeatures.buildconfig=false  # disable unused features
android.defaults.buildfeatures.aidl=false
11 · Dependency Management

Version Catalogs

Version Catalogs (libs.versions.toml) are the modern, type-safe way to manage dependencies across a multi-module project. They replace buildSrc/Dependencies.kt, Kotlin objects with version constants, and ext properties — all of which have worse IDE support and caching behavior.

gradle/libs.versions.toml
RECOMMENDED
[versions]
agp = "8.3.0"
kotlin = "1.9.22"
ksp = "1.9.22-1.0.17"
compose-bom = "2024.02.00"
hilt = "2.50"
room = "2.6.1"
retrofit = "2.9.0"
coroutines = "1.7.3"
lifecycle = "2.7.0"

[libraries]
# Compose — using BOM for consistent versions
compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "compose-bom" }
compose-ui = { group = "androidx.compose.ui", name = "ui" }
compose-material3 = { group = "androidx.compose.material3", name = "material3" }

# Hilt
hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" }
hilt-compiler = { group = "com.google.dagger", name = "hilt-android-compiler", version.ref = "hilt" }

# Room
room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }

[bundles]
# Declare groups of libraries applied together
room = ["room-runtime", "room-ktx"]
compose = ["compose-ui", "compose-material3"]

[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
android-library = { id = "com.android.library", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
Using the catalog in build.gradle.kts
plugins {
    alias(libs.plugins.android.application)
    alias(libs.plugins.kotlin.android)
    alias(libs.plugins.hilt)
    alias(libs.plugins.ksp)
}

dependencies {
    implementation(platform(libs.compose.bom))  // BOM manages all compose versions
    implementation(libs.compose.ui)
    implementation(libs.compose.material3)

    implementation(libs.hilt.android)
    ksp(libs.hilt.compiler)

    implementation(libs.bundles.room)    // bundle: room-runtime + room-ktx
    ksp(libs.room.compiler)

    // Type-safe: libs.compose.ui auto-completes in IDE, no magic strings
}
12 · Extensibility

Custom Tasks

Gradle's power comes from its extensibility. Custom tasks let you add project-specific build steps — generating code, validating configurations, publishing artifacts, running checks — as first-class citizens in your build that benefit from caching, UP-TO-DATE checking, and parallel execution.

Custom tasks — patterns & best practices
// ── Pattern 1: Typed task class (recommended for reuse) ────────
@CacheableTask
abstract class GenerateApiModelsTask : DefaultTask() {

    @get:InputDirectory
    @get:PathSensitive(PathSensitivity.RELATIVE)
    abstract val schemaDir: DirectoryProperty

    @get:OutputDirectory
    abstract val outputDir: DirectoryProperty

    @TaskAction
    fun generate() {
        schemaDir.get().asFile.walkTopDown()
            .filter { it.name.endsWith(".json") }
            .forEach { schema -> generateModelFrom(schema, outputDir.get().asFile) }
    }
}

// Register and wire into the build
val generateModels = tasks.register<GenerateApiModelsTask>("generateApiModels") {
    schemaDir.set(layout.projectDirectory.dir("schemas"))
    outputDir.set(layout.buildDirectory.dir("generated/models"))
}
// Wire generated sources into compilation
android.sourceSets["main"].java.srcDir(generateModels)

// ── Pattern 2: Verification task ───────────────────────────────
tasks.register("verifyDependencies") {
    description = "Fails if any dependency uses a dynamic version"
    group = "verification"
    doLast {
        configurations.filter { it.isCanBeResolved }.flatMap { it.dependencies }
            .filter { dep -> dep.version?.contains("+") == true }
            .takeIf { it.isNotEmpty() }
            ?.let { throw GradleException("Dynamic dependencies found: $it") }
    }
}
// Run automatically before every build
tasks.named("preBuild").configure { dependsOn("verifyDependencies") }
13 · Interactive

Build Simulator

Simulate different build scenarios and see how caching, parallel execution, and module structure affect total build time.

GRADLE BUILD SIMULATOR
Parallel execution: ENABLED
Total time
Tasks run
From cache
Up-to-date
> Configure Gradle build simulator. Press Run Build to start.