./gradlew build before. Terms such as configuration cache, version catalog and Kotlin DSL are explained where they first appear.
Versions tested. Gradle 9.8.0 on JDK 25.0.4.1 and Gradle 8.14.3 on JDK 21, with Spring Boot 4.1.1, on 28 September 2026. Companion code: spring-boot-demo/gradle9.
The starting point: a Groovy build that works today
The project is a Spring Boot web application with one endpoint and one test. Itsbuild.gradle uses three habits that were normal for years: setting sourceCompatibility and archivesBaseName directly on the project, and a small custom task that reads the project and an environment variable while it runs.
plugins {
id 'java'
id 'org.springframework.boot' version '4.1.1'
id 'io.spring.dependency-management' version '1.1.7'
}
group = 'com.ankurm'
version = '0.0.1'
sourceCompatibility = '17'
archivesBaseName = 'g9demo-legacy'
repositories {
mavenCentral()
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-webmvc'
testImplementation 'org.springframework.boot:spring-boot-starter-webmvc-test'
}
// Task that reads the project model and an environment variable while it EXECUTES
tasks.register('buildInfo') {
doLast {
def stamp = System.getenv('BUILD_STAMP') ?: 'local'
file("$buildDir/build-info.txt").text = "${project.name} ${project.version} ${stamp}\n"
}
}
Source: build.gradle, lines 1–27.
On Gradle 8.14.3 this builds. It is not clean, though. Gradle prints deprecation warnings for two of those habits, and each warning says when the feature goes away:$ gradle build --warning-mode all # Gradle 8.14.3, JDK 21, legacy Groovy build
The org.gradle.api.plugins.JavaPluginConvention type has been deprecated. This is scheduled to be removed in Gradle 9.0. Consult the upgrading guide for further information: https://docs.gradle.org/8.14.3/userguide/upgrading_version_8.html#java_convention_deprecation
The org.gradle.api.plugins.BasePluginConvention type has been deprecated. This is scheduled to be removed in Gradle 9.0. Consult the upgrading guide for further information: https://docs.gradle.org/8.14.3/userguide/upgrading_version_8.html#base_convention_deprecation
The BasePluginExtension.archivesBaseName property has been deprecated. This is scheduled to be removed in Gradle 9.0. Please use the archivesName property instead. For more information, please refer to https://docs.gradle.org/8.14.3/dsl/org.gradle.api.plugins.BasePluginExtension.html#org.gradle.api.plugins.BasePluginExtension:archivesName in the Gradle documentation.
BUILD SUCCESSFUL
Captured in 01-legacy-on-gradle8.txt.
Deprecation warnings are Gradle giving you a migration window. The window closes at the next major version, which is where the next section begins.What breaks the moment you run Gradle 9
Running the unchanged build on Gradle 9.8.0 stops at the first removed property. Fixing that reveals the second one, so the failures arrive one at a time:$ gradle build # Gradle 9.8.0, JDK 25, same legacy build, unchanged
Build file 'WORK/b/build.gradle' line: 9
* What went wrong:
> Could not set unknown property 'sourceCompatibility' for root project 'g9demo' of type org.gradle.api.Project.
BUILD FAILED
Captured in 02-legacy-on-gradle9-sourceCompatibility.txt.
$ gradle build # after replacing sourceCompatibility = '17' with java { sourceCompatibility = ... }
Build file 'WORK/c/build.gradle' line: 10
* What went wrong:
> Could not set unknown property 'archivesBaseName' for root project 'g9demo' of type org.gradle.api.Project.
BUILD FAILED
Captured in 03-legacy-on-gradle9-archivesBaseName.txt.
java { sourceCompatibility = ... } and base { archivesName = ... }. With both applied, the build passes on Gradle 9, but with a new warning:
$ gradle build buildInfo --warning-mode all # both removals fixed
Invocation of Task.project at execution time has been deprecated. This will fail with an error in Gradle 10. This API is incompatible with the configuration cache, which will become the only mode supported by Gradle in a future release. Consult the upgrading guide for further information: https://docs.gradle.org/9.8.0/userguide/upgrading_version_7.html#task_project
BUILD SUCCESSFUL
Captured in 04-legacy-fixed-on-gradle9.txt.
This warning is the real subject of the article. ReadingTask.projectwhile a task executes is what the custom task does (project.nameinsidedoLast). Gradle 9 still allows it but says it will fail in Gradle 10, and that it is incompatible with the configuration cache.
Going deeper: the removed properties
Both removed properties were replaced by extension objects (
java and base) that exist so the values can be Gradle properties instead of plain fields. I only hit these two because this small project uses only these two; a larger build may hit others. Run it on Gradle 8.14 with --warning-mode all first and fix every warning before upgrading.
The configuration cache in one paragraph
A Gradle build has two phases. In the configuration phase Gradle runs your build scripts to work out which tasks exist and what they depend on. In the execution phase it runs the tasks. The configuration cache saves the result of the first phase to disk, so the next build with the same inputs can skip it entirely and go straight to execution. To save the result, Gradle has to be able to write every task to disk, which is why tasks may not reach back into the liveProject object while running: the cached copy has no project to reach into.
project during execution cannot work. Turn it on for one build with --configuration-cache; the legacy build, with both removed properties fixed, fails immediately:
$ gradle buildInfo --configuration-cache # same fixed legacy build
> Task :buildInfo FAILED
1 problem was found storing the configuration cache.
- Build file 'build.gradle': line 25: invocation of 'Task.project' at execution time is unsupported with the configuration cache.
[Incubating] Problems report is available at: file://WORK/d/build/reports/problems/problems-report.html
Build file 'WORK/d/build.gradle' line: 25
* What went wrong:
Execution failed for task ':buildInfo' (registered in build file 'build.gradle').
> Invocation of 'Task.project' by task ':buildInfo' at execution time is unsupported with the configuration cache.
BUILD FAILED
Configuration cache entry discarded with 1 problem.
Captured in 05-legacy-configuration-cache.txt.
Notice that the message names the task, the line of the build file, and the exact API. That is the pattern for all configuration cache problems: read the task name and line, then remove the live object from the execution phase.Fixing the task: values in, not Project out
The fix is to give the task its inputs as properties that are filled in during configuration, and to write the file from those. A typed task with@Input and @OutputFile also lets Gradle skip the task when nothing changed. Here is the migrated build, now in the Kotlin DSL:
// Configuration-cache-safe: a typed task whose inputs are Property/Provider values,
// so nothing reaches back into the script or Project when the task executes.
abstract class BuildInfo : DefaultTask() {
@get:Input abstract val text: Property<String>
@get:OutputFile abstract val target: RegularFileProperty
@TaskAction fun write() { target.get().asFile.writeText(text.get()) }
}
tasks.register<BuildInfo>("buildInfo") {
val prefix = "${project.name} ${project.version} " // read at configuration time
text = providers.environmentVariable("BUILD_STAMP").orElse("local").map { "$prefix$it\n" }
target = layout.buildDirectory.file("build-info.txt")
}
tasks.named<Test>("test") { useJUnitPlatform() }
Source: build.gradle.kts, lines 21–35.
Three details matter. The environment variable is read withproviders.environmentVariable, not System.getenv, so Gradle knows the build depends on it. project.name and project.version are read in the configuration block and captured as a plain string. And the output goes to layout.buildDirectory, the replacement for the deprecated buildDir.
A trap I hit while writing this. My first attempt kept the task as a lambda that captured script variables and the run failed with cannot serialize Gradle script object references (a message I saw on screen but did not capture in the transcripts). The cache has to store the task, and a lambda that points at the build script cannot be stored. Moving the logic into a typed task class fixed it.Now the same command twice. The first run stores the cache entry, the second reuses it and every task reports up to date; changing the environment variable invalidates the entry, and the new value shows in the output file:
$ ./gradlew buildInfo test --configuration-cache # migrated build, first run
> Task :buildInfo
> Task :testClasses
> Task :test
BUILD SUCCESSFUL
Configuration cache entry stored.
$ ./gradlew buildInfo test --configuration-cache # second run
Reusing configuration cache.
> Task :buildInfo UP-TO-DATE
> Task :testClasses UP-TO-DATE
> Task :test UP-TO-DATE
BUILD SUCCESSFUL
Configuration cache entry reused.
$ BUILD_STAMP=ci42 ./gradlew buildInfo --configuration-cache # env var changed
BUILD SUCCESSFUL
Configuration cache entry stored.
$ cat build/build-info.txt
g9demo 0.0.1 ci42
Captured in 06-migrated-configuration-cache.txt.
Going deeper: what else the cache checks
The cache entry is invalidated by any input Gradle has been told about. That is why reading an environment variable through
providers matters: with a plain System.getenv, Gradle would not know to rebuild when the value changed. I only tested the environment variable and the build scripts as inputs.
Version catalogs: one file for versions
A version catalog is a file namedgradle/libs.versions.toml that gives dependencies and plugins short names. Build scripts then refer to libs.plugins.spring.boot instead of a version string, so a version lives in one place. This one covers the two plugins and two starters:
[versions]
spring-boot = "4.1.1"
dependency-management = "1.1.7"
[libraries]
starter-webmvc = { module = "org.springframework.boot:spring-boot-starter-webmvc" }
starter-webmvc-test = { module = "org.springframework.boot:spring-boot-starter-webmvc-test" }
[plugins]
spring-boot = { id = "org.springframework.boot", version.ref = "spring-boot" }
dependency-management = { id = "io.spring.dependency-management", version.ref = "dependency-management" }
Source: libs.versions.toml.
In Spring Boot builds the starters have no version here, because the Boot plugin and dependency management supply it. The catalog only names them. The plugin block in the Kotlin script (lines 1–5 of the build file above) refers to the aliases by generated accessor names, and the migrated project builds and passes its test with them:$ ./gradlew build # migrated build on the Gradle wrapper (9.8.0), tests included
> Task :bootJar
> Task :testClasses
> Task :test
BUILD SUCCESSFUL
7 actionable tasks: 7 executed
$ ls build/libs
g9demo-0.0.1-plain.jar
g9demo-0.0.1.jar
Captured in 07-migrated-build.txt.
Kotlin DSL: worth it, but not required
The migrated build is in the Kotlin DSL (build.gradle.kts), and the visible difference is that the script is typed: options.release = 17 is an assignment to a typed property, and a misspelled name fails when the script compiles. I did not compare editor support. I did not measure whether Kotlin scripts are slower to compile, so I make no claim about speed. Gradle 9 works with Groovy builds after the two fixes above, so switching DSL is a separate decision from migrating to Gradle 9.
Gradle version and JDK: pick them as a pair
The Gradle version that runs your build must understand the JDK it runs on. The migrated project on Gradle 8.14.3 with JDK 25 fails before doing any work, with a message that is only the JDK version string:$ gradle build # Gradle 8.14.3 on JDK 25 (launcher JVM), migrated build
FAILURE: Build failed with an exception.
* What went wrong:
25.0.4.1
* Try:
> Run with --stacktrace option to get the stack trace.
> Run with --info or --debug option to get more log output.
> Run with --scan to get full insights.
> Get more help at https://help.gradle.org.
BUILD FAILED
Captured in 08-gradle8-on-jdk25.txt.
So a team moving to JDK 25 has to move to Gradle 9 at the same time. The project compiles for Java 17 throughoptions.release = 17, so the JDK running Gradle and the Java level of your code are independent choices.
Should you migrate now?
Yes, in this order. First, on Gradle 8.14, run--warning-mode alland fix every deprecation. Second, try--configuration-cacheand fix the tasks it names. Third, upgrade the wrapper to 9.x. The last step then has nothing left to break. If a third-party plugin fails under the cache, that is a plugin issue: keep the cache off for that build, but fix your own tasks anyway, since the Gradle 9 warning says it becomes an error in Gradle 10.
No Comments yet!