Most Java developers meet Gradle as a file they copy from another project and edit until the build turns green. That works until the build takes six minutes, a transitive dependency changes version without anyone touching a file, or the cache that should have made CI fast never hits. Each of those problems has the same root: Gradle is not a script runner but a graph engine, and it is only as fast and as correct as the information you give it about inputs, outputs and dependencies.
This article explains Gradle from that engine outward. It covers the three phases every build goes through, builds a small multi-project example with a version catalog and a convention plugin, explains how dependencies are resolved and why versions change under you, writes a custom task that is incremental and cacheable, and shows how the configuration cache and build cache work. It ends with failure modes and a checklist. Examples use the Kotlin DSL and APIs that work on Gradle 9, which was released on 31 July 2025, requires Java 17 or later to run Gradle itself, and makes the configuration cache the preferred mode of execution.
Three phases, one graph
In the initialization phase Gradle reads settings.gradle.kts to learn which projects the build contains and which other builds are included. In the configuration phase it runs each project's build script, which applies plugins and registers tasks, and it builds a directed acyclic graph of the tasks you asked for plus everything they depend on. In the execution phase it walks that graph. For each task it fingerprints the declared inputs and outputs; if nothing changed since the last run the task is up to date and skipped, and if the build cache holds outputs for the same inputs they are restored instead of rebuilt.
Two consequences follow. First, anything you write at the top level of a build script runs during configuration, for every build, even ./gradlew help. A network call or a file scan there taxes every invocation. Second, a task that does not declare an input Gradle cannot see will be wrongly skipped, and a task that does not declare its outputs can never be skipped or cached. Correctness and speed both come from declarations.
Follow one command through it. ./gradlew :app:build asks for the build task of the app project. Configuration produces a graph in which build depends on check and assemble, assemble on jar, jar on compileJava and processResources, and :app:compileJava on core's compiled classes; :core:jar joins the graph through the app's runtime classpath. Run it twice without edits and the second run reports every task as up to date. Change one test file in core and only :core:compileTestJava and :core:test rerun; :app:compileJava stays up to date because the core classes it compiled against did not change. Use --dry-run to print the graph without executing it, and --console=verbose to see each task's outcome.
The long-lived daemon process sits under all this. The gradlew wrapper starts or reuses a warm JVM that keeps class loaders, JIT-compiled code and file-system state between builds, which is why the second build in a terminal is faster than the first. Its heap is set with org.gradle.jvmargs in gradle.properties.
A multi-project build, worked through
Take a service with an app module and a core library. Shared conventions live in an included build called build-logic, and versions live in a version catalog. The settings file names everything:
// settings.gradle.kts
pluginManagement { includeBuild("build-logic") }
dependencyResolutionManagement {
repositories { mavenCentral() }
}
rootProject.name = "orders"
include("core", "app")# gradle/libs.versions.toml (versions are examples; pin your own)
[versions]
jackson = "2.17.2"
junit = "5.11.0"
[libraries]
jackson-bom = { module = "com.fasterxml.jackson:jackson-bom", version.ref = "jackson" }
jackson-databind = { module = "com.fasterxml.jackson.core:jackson-databind" }
junit-bom = { module = "org.junit:junit-bom", version.ref = "junit" }
junit-jupiter = { module = "org.junit.jupiter:junit-jupiter" }
junit-launcher = { module = "org.junit.platform:junit-platform-launcher" }The convention plugin is an ordinary build script compiled into a plugin. Every module that applies it gets the same Java toolchain, test setup and compiler flags, so a change happens in one place:
// build-logic/build.gradle.kts
plugins { `kotlin-dsl` }
repositories { gradlePluginPortal() }
// build-logic/src/main/kotlin/orders.java-conventions.gradle.kts
plugins { `java-library` }
java { toolchain { languageVersion.set(JavaLanguageVersion.of(21)) } }
tasks.withType<JavaCompile>().configureEach {
options.compilerArgs.add("-Xlint:all")
}
tasks.withType<Test>().configureEach {
useJUnitPlatform()
maxParallelForks = (Runtime.getRuntime().availableProcessors() / 2).coerceAtLeast(1)
}// core/build.gradle.kts
plugins { id("orders.java-conventions") }
dependencies {
api(platform(libs.jackson.bom))
api(libs.jackson.databind) // core's public API exposes Jackson types
testImplementation(platform(libs.junit.bom))
testImplementation(libs.junit.jupiter)
testRuntimeOnly(libs.junit.launcher)
}
// app/build.gradle.kts
plugins { id("orders.java-conventions"); application }
dependencies { implementation(project(":core")) }
application { mainClass.set("orders.Main") }The toolchain line means the build compiles and tests with Java 21 even if Gradle itself runs on Java 17; Gradle uses a matching installed JDK; downloading one automatically needs a toolchain resolver plugin declared in the settings file. withType<...>().configureEach configures tasks lazily, only if they are actually created. Declaring the JUnit launcher explicitly, rather than relying on Gradle to inject it, keeps the test runtime classpath predictable across Gradle versions. For more on JUnit's engine model, see JUnit 5 in depth.
Dependencies: configurations and conflict resolution
A configuration is a named bucket of dependencies. You declare into buckets such as api, implementation, compileOnly and runtimeOnly; Gradle resolves derived classpaths such as compileClasspath and runtimeClasspath from them. The difference between api and implementation is about consumers: an api dependency is on the compile classpath of every project that depends on yours, an implementation dependency is not. Overusing api leaks libraries into consumers and makes every change to them recompile the world; use it only for types that appear in your public signatures. This mirrors the exports discipline of Java modules.
When two paths through the graph request different versions of one module, Gradle by default picks the highest requested version. That is why a dependency can move without anyone editing your build: a new library version you added pulls a newer transitive version of something else, and every other user of that module silently gets it. Platforms (BOMs) and constraints let you take control, dependencyInsight tells you why a version was chosen, and locking freezes the result:
./gradlew :app:dependencyInsight --dependency jackson-core --configuration runtimeClasspath
// build.gradle.kts: forbid surprise upgrades of a sensitive module
dependencies {
constraints {
implementation("org.yaml:snakeyaml") {
version { strictly("[2.2, 3.0[") }
because("1.x has unsafe default constructors")
}
}
}
// in the orders.java-conventions plugin, so every project locks its configurations
dependencyLocking { lockAllConfigurations() }
# write one gradle.lockfile per project, then commit them
./gradlew :core:dependencies :app:dependencies --write-locksFor supply-chain safety, ./gradlew --write-verification-metadata sha256 help writes gradle/verification-metadata.xml with checksums for every artifact; later builds fail if a downloaded artifact does not match. The wrapper itself should be pinned too: set distributionSha256Sum in gradle/wrapper/gradle-wrapper.properties so a tampered distribution is rejected.
Tasks that skip themselves: inputs, outputs, lazy properties
A custom task becomes incremental and cacheable when its inputs and outputs are declared as typed properties. Here is a task that writes build metadata, registered lazily so it costs nothing in builds that do not need it:
@CacheableTask
abstract class BuildInfo : DefaultTask() {
@get:Input abstract val appVersion: Property<String>
@get:Input abstract val commit: Property<String>
@get:OutputDirectory abstract val outputDir: DirectoryProperty
@TaskAction
fun write() {
val file = outputDir.file("build-info.properties").get().asFile
file.writeText("version=${appVersion.get()}\ncommit=${commit.get()}\n")
}
}
val buildInfo = tasks.register<BuildInfo>("buildInfo") {
appVersion.set(project.version.toString())
commit.set(providers.exec { commandLine("git", "rev-parse", "HEAD") }
.standardOutput.asText.map { it.trim() })
outputDir.set(layout.buildDirectory.dir("generated/build-info"))
}
sourceSets.main { resources.srcDir(buildInfo) } // wires the task dependency tooThree details carry the design. tasks.register defers creating and configuring the task until something needs it, unlike the eager tasks.create. The Property and Provider types are lazy, so the commit hash is computed only when the task runs and is tracked as an input; the task reruns when the commit changes and is skipped otherwise. Passing the task provider to srcDir adds both the output directory and the dependency, so processResources runs buildInfo first without a hand-written dependsOn. Notice what is not an input: a timestamp. Writing the current time into the file would make every build a cache miss for this task and everything downstream.
For file inputs, annotate with @InputFiles and @PathSensitive(PathSensitivity.RELATIVE) so the cache key ignores where the checkout lives; absolute paths in cache keys are the most common reason a remote build cache never hits across machines.
The build cache and the configuration cache
The build cache stores task outputs keyed by a hash of the task's implementation and inputs. With org.gradle.caching=true Gradle uses a local cache, and you can add a shared remote cache so CI populates it and developers read from it. Only tasks marked cacheable participate; the Java compile, test and resource tasks are, and your own tasks are once annotated as above. A typical policy is that CI on the main branch pushes to the remote cache and everything else only pulls.
The configuration cache stores the result of the configuration phase, the task graph with all task inputs resolved, so a repeated invocation with the same build files skips initialization and configuration entirely. It is enabled with org.gradle.configuration-cache=true. To be cacheable, tasks may not reach back into the live Project model at execution time; that is why the example uses providers.exec instead of running a process directly, and layout.buildDirectory instead of the old buildDir property. Gradle records external inputs it reads during configuration, such as environment variables, system properties and files, and invalidates the cache entry when any of them change. On Gradle 9 it is the preferred mode, and Gradle reports incompatible plugins or build logic rather than silently misbehaving.
# gradle.properties
org.gradle.caching=true
org.gradle.configuration-cache=true
org.gradle.parallel=true
org.gradle.jvmargs=-Xmx3g -XX:+HeapDumpOnOutOfMemoryError
Failure modes and how they show up
| Symptom | Cause | Fix |
|---|---|---|
| Every command takes seconds before any task runs | Work at configuration time: network calls, file scans, eager tasks | Move work into task actions or providers; use tasks.register; profile with a build scan |
| Task always reruns | Undeclared or unstable input such as a timestamp or absolute path | Declare inputs; use relative path sensitivity; remove volatile values |
| Wrong output, task skipped | An input the task reads is not declared | Declare it; never read files the task does not list |
| Remote cache never hits | Different JDKs, absolute paths, or machine-specific inputs | Toolchains, path sensitivity, compare cache keys in build scans |
| Library version changed by itself | Highest-version conflict resolution | Platforms, constraints, dependency locking |
| Whole build recompiles on a small change | Implementation dependencies exposed as api | Use implementation unless the type is in your public API |
| Overlapping outputs warning | Two tasks write to one directory | Give each task its own output directory |
| Daemon out of memory | Large multi-project build with default heap | Raise org.gradle.jvmargs; check for leaking plugins |
Gradle versus Maven is mostly this trade: Maven's fixed lifecycle is simpler to read and harder to misuse, while Gradle's graph, incremental tasks and caches make large builds much faster at the cost of discipline about inputs and outputs. A Gradle build written like a shell script gets Maven's speed with more complexity than Maven. Packaging a lean runtime image afterwards is covered in jlink in depth.
What to do next
- Run
./gradlew help --scanor--profileand measure how long configuration takes before any task runs. - Replace every
tasks.createand top-level side effect withtasks.registerand providers. - Move shared configuration into a convention plugin in an included
build-logicbuild. - Put versions in
gradle/libs.versions.tomland import BOMs withplatform(...). - Audit
apidependencies and downgrade toimplementationwhere types are not exposed. - Enable
org.gradle.cachingandorg.gradle.configuration-cache, fix what Gradle reports, then add a remote cache that CI writes. - Commit dependency lock files and verification metadata, and pin
distributionSha256Sumfor the wrapper. - Use
dependencyInsightwhenever a version surprises you, before adding a forced version.