skip to content

gradle

tasks, the task graph, and the kotlin dsl.

chapter
chapter 12.06
reading time
7 min read

gradle turns your build into a graph of tasks. you ask for one task, gradle runs only the tasks it depends on, in order, and skips every task whose inputs haven't changed since last time.

the build script is real code (kotlin, usually), it runs in three phases: initialization, configuration, execution. knowing which phase a line runs in is most of the battle.

the picture

maven is an assembly line: fixed stations, always in the same order. gradle is a kitchen with a recipe graph. to serve dinner you need pasta and sauce, the sauce needs chopped onions, the pasta needs boiling water. ask for "sauce" and only the onions and the sauce happen. and if the onions you chopped yesterday are still in the fridge, unchanged, nobody chops them again.

that "still in the fridge" check is the whole trick. every task declares its inputs (source files, classpath, settings) and outputs (class files, a jar). gradle fingerprints both. same inputs, outputs still there: UP-TO-DATE, skipped.

a build script

this is a complete build for a small app with one library and junit. gradle 9.8.1 built it on jdk 25. the kotlin DSL (build.gradle.kts) is what gradle init generates by default now; the older groovy DSL (build.gradle) still works.

build.gradle.kts
plugins {
    application                         // java + a run task + start scripts + zips
}
 
group = "com.acme"
version = "1.0"
 
repositories {
    mavenCentral()
}
 
dependencies {
    implementation("com.google.guava:guava:33.7.2-jre")
    testImplementation("org.junit.jupiter:junit-jupiter:6.1.3")
    testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}
 
java {
    toolchain {
        languageVersion = JavaLanguageVersion.of(25)   // compile + test with a JDK 25
    }
}
 
application {
    mainClass = "com.acme.app.App"
}
 
tasks.test {
    useJUnitPlatform()
}

it uses the same directory layout as maven (src/main/java, src/test/java) and the same repositories. the difference is what happens when you run it.

the task graph

plugins add tasks and wire them together. with the application plugin, build pulls in 14 tasks. click any task to make it the target, then pretend you edited something.

gradle =
after you =
compileJavadid workprocessResourcesUP-TO-DATEclassesranjardid workstartScriptsdid workdistTardid workdistZipdid workassemblerancompileTestJavaUP-TO-DATEprocessTestResourcesNO-SOURCEtestClassesUP-TO-DATEtestdid workcheckranbuildran
did work skipped lifecycle task (no action of its own)→ runs before (implied edges hidden)

compileJava reran, but compileTestJava didn't: a method body changed, the class's public API (its ABI) didn't, so nothing compiled against it needs recompiling. tests still rerun.

--console=plain
$ gradle build
> Task :compileJava
> Task :processResources UP-TO-DATE
> Task :classes
> Task :jar
> Task :startScripts
> Task :distTar
> Task :distZip
> Task :assemble
> Task :compileTestJava UP-TO-DATE
> Task :processTestResources NO-SOURCE
> Task :testClasses UP-TO-DATE
> Task :test
> Task :check
> Task :build
BUILD SUCCESSFUL
8 actionable tasks: 6 executed, 2 up-to-date
pick a target and an edit. the graph is gradle's own (gradle build --task-graph) and every state is from a real run. the one to try: edit a method body, and watch compileTestJava stay skipped.
quick check
you change the body of a private method in App.java. which of these does gradle NOT rerun on the next build?

three phases, and why it matters

a gradle build always runs in the same three phases:

hover a stage.

hover a phase. the configuration phase runs on every single build, even when every task ends up UP-TO-DATE.

you can watch it happen with a few printlns:

build.gradle.kts
println("2. build.gradle.kts: configuration")
 
tasks.register("hello") {
    println("3. configuring hello")       // configuration phase
    doLast {
        println("4. running hello")       // execution phase
    }
}
 
tasks.register("other") {
    println("configuring other")          // never printed
}
terminal
$ gradle hello -q
1. settings.gradle.kts: initialization
2. build.gradle.kts: configuration
3. configuring hello
4. running hello
$ gradle help -q
1. settings.gradle.kts: initialization
2. build.gradle.kts: configuration

notice configuring other never shows up, and hello isn't even configured when you run help. tasks.register is lazy: a task's configuration block only runs if that task ends up in the graph.

dependencies and configurations

gradle's version of maven scopes is the configuration (overloaded word, sorry). the ones you'll use:

configurationon the compile classpathat runtimemaven equivalent
implementationyesyescompile, but not leaked to your consumers' compile classpath
api (java-library plugin)yes, and your consumers' tooyescompile
compileOnlyyesnoprovided
runtimeOnlynoyesruntime
testImplementationtests onlytests onlytest

and here's a real difference from maven. same two libraries as the maven chapter, json-lib wanting text-utils 1.0 and http-lib wanting 2.0:

terminal
$ gradle dependencies --configuration runtimeClasspath
runtimeClasspath - Runtime classpath of source set 'main'.
+--- com.acme:json-lib:1.0
|    \--- com.acme:text-utils:1.0 -> 2.0
\--- com.acme:http-lib:2.0
     \--- com.acme:core-lib:2.0
          \--- com.acme:text-utils:2.0

gradle picked 2.0, the highest version, and the 1.0 -> 2.0 arrow tells you it overrode a request. maven picked 1.0 (nearest) and the app crashed with NoSuchMethodError. neither rule is always right: highest-wins can also break things if 2.0 removed something 1.0 had. but at least you know which rule you're living with.

the speed stack

why gradle builds get faster the more you run them:

up-to-date checks
skip a task whose input and output fingerprints match the last run. on by default. the figure above.
the daemon
a long-lived background JVM that keeps the build model and a warmed-up JIT between builds. on by default.
build cache
reuse a task's outputs from any earlier build with the same inputs, even another branch or another machine (with a remote cache). --build-cache or org.gradle.caching=true.
configuration cache
skip the whole configuration phase when nothing in the build scripts changed. gradle 9 recommends it ("Consider enabling configuration cache to speed up this build", it printed) but still doesn't turn it on for you.
one level deeper:the wrapper, and toolchains+

commit gradlew, gradlew.bat and gradle/wrapper/ to the repo. ./gradlew build downloads exactly the gradle version the project pins, so a fresh clone never needs gradle installed and everyone builds with the same one. toolchains (the java { toolchain { } } block) do the same for the JDK: gradle itself might run on jdk 21 while compiling and testing your code with jdk 25. it finds installed JDKs on its own, and with a toolchain resolver plugin (foojay's is the common one) it can download a missing one.

keylines
  • gradle runs a graph of tasks, only the ones your target needs.
  • every task fingerprints its inputs and outputs. unchanged = UP-TO-DATE, skipped.
  • a body-only change skips recompiling the tests: compile avoidance.
  • configuration runs every build. real work goes in doLast.
  • version conflicts: gradle picks the highest, maven picks the nearest.