Groovy DSL & Migrating to Kotlin
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against Gradle 9.8.1 (released 2026-10-07), which embeds Groovy 4.0.33 (Groovy 4.0.27 in 9.0.0; 3.0.24 in the last 8.x releases). Every example
was run through the 9.8.1 wrapper on 2026-10-16, on macOS, with a JDK 26 daemon; the tidepool modules compile with a Java 21 toolchain. Examples that differ between the
two DSLs show the Kotlin DSL first and the Groovy DSL second, and both were run; where a construct exists only in Groovy, only Groovy is shown. Examples were run with
--configuration-cache unless the text says otherwise, and the text says whether each is compatible. Gradle 8.14 was not available here, so statements about 8.14 come from the
release notes and are labelled "not run here". The scratch directory is shown as <work>.
The Groovy DSL is the original language of Gradle build scripts: build.gradle, settings.gradle and init scripts written in Groovy, a dynamic JVM language, run
against the same API as the Kotlin DSL on Kotlin DSL. Nothing here is a reason to avoid it: millions of
builds use it, and every Gradle example in the older literature is in Groovy. The Gradle best practices recommend the Kotlin DSL for new builds, and the Groovy side
of this page exists so that you can read an existing script and move it. The language itself is documented in the Groovy section: start with
Build Tools: Gradle, Maven and Ant, and see
Closures,
Closure Delegation: Owner, Delegate and This and
Domain-Specific Languages for the mechanics that Gradle’s blocks are made of.
In the three-tool picture, Ant and Maven build files are XML, so there is no language choice to make; Ant can embed Groovy in a target through the script task
(see Scripting in Ant).
The Groovy build-script primer
Gradle’s manual calls its description of the Groovy constructs the "Groovy build script primer". A Groovy build script ideally looks like configuration: set properties of the project, configure dependencies, declare tasks. This section follows the manual’s structure and runs each construct.
The Project object
Every Groovy build script is backed by an implicit instance of org.gradle.api.Project. At the top level of the script (not nested in a {} block), an unqualified name
is a property or a method of that Project: in version = '1.0.0.GA' and configurations { ... }, both version and configurations belong to Project. When you see an
unqualified element and do not know where it comes from, look it up in the Project API documentation first. The manual also warns against Groovy MetaClass
programming in build scripts: it can make the daemon retain large amounts of memory between builds and eventually run out of it.
Properties, methods and blocks
| Groovy form | Meaning |
|---|---|
|
get a property value |
|
set a property to a new value |
|
embed a value in a string (a GString) |
|
a method call; with |
|
a block: a method whose last argument is an |
|
a local variable |
A property is implemented by methods: it can be read when there is a zero-argument get<PropertyName> method returning the property’s type, and written when there is a one-argument
set<PropertyName> returning void. The first letter is lower case in the property and upper case in the method name, so getProjectVersion() is projectVersion, except that a name
that starts with two upper-case letters keeps its case, so getRAM() is RAM. That is why the Groovy DSL reference shows properties while the Javadoc shows only methods. A name
without a qualifier can be a task with that name, a property of Project, an extra property, a property of the implicit object inside a block, or a local variable declared earlier. A method
with the same name as a collection-valued property appends to the collection by convention, which is why include '**/*.xml', '**/*.properties' adds patterns.
A block is a method whose last argument has type Action or Closure. Inside the block, unqualified names resolve against the block’s delegate. To find the delegate, look at
the method signature: for Action<? super CopySpec> it is the type in the angle brackets (CopySpec); for a Closure argument, the documentation says in words which type is
being configured. Project.copy(Action) is an example from the manual: in copy { into ...; from ... } the copy and layout are found on Project, while into and from are
found on the delegate CopySpec. Newer Gradle APIs declare Action arguments, and older ones have an Action overload beside the Closure one, which makes the delegate type
easy to pick out.
The script below runs each of these constructs. The tasks.register('copyData', Copy) { ... } block shows the delegation: name inside it is the task’s name, while
project.name is the project’s. It also shows the order of evaluation, because the block of a lazily registered task runs only when the task is needed, after the lines that
follow it in the script:
plugins {
id 'java'
}
// Properties: the implicit Project object is the receiver
group = 'io.tidepool' // Project.setGroup(String)
version = '1.0.1' // Project.setVersion(Object)
description = 'Tide predictions'
// A local variable: visible in this script only
def prefix = 'tidepool'
// Methods: with and without parentheses
println "project : ${project.group}:${project.name}:${project.version}"
println "getter : ${project.getVersion()} (same as project.version)"
println "file() : ${file('data/stations.csv').name}"
// Blocks are methods whose last argument is an Action or a Closure.
// Inside the block, unqualified names resolve against the delegate (the Copy task).
tasks.register('copyData', Copy) {
from 'data' // Copy.from(Object...)
into layout.buildDirectory.dir('copied')
include '**/*.csv', '**/*.properties' // method call, several arguments, no parentheses
println "inside the block: name=${name} project.name=${project.name} prefix=${prefix}"
}
// A closure with an explicit delegate
class Station { String name = 'San Francisco' }
Closure show = { println "closure: name=${name}" }
show.delegate = new Station()
show.resolveStrategy = Closure.DELEGATE_FIRST
show()
plugins {
java
}
// Properties: the implicit Project object is the receiver
group = "io.tidepool" // Project.setGroup(String)
version = "1.0.1" // Project.setVersion(Object)
description = "Tide predictions"
// A local variable: visible in this script only
val prefix = "tidepool"
// Methods: parentheses are always required
println("project : ${project.group}:${project.name}:${project.version}")
println("getter : ${project.version} (same as project.getVersion())")
println("file() : ${file("data/stations.csv").name}")
// Blocks are lambdas with receiver: inside, 'this' is the Copy task
tasks.register<Copy>("copyData") {
from("data") // Copy.from(Object...)
into(layout.buildDirectory.dir("copied"))
include("**/*.csv", "**/*.properties")
println("inside the block: name=${name} project.name=${project.name} prefix=${prefix}")
}
// A lambda with receiver instead of a closure with a delegate
class Station { var name = "San Francisco" }
val show: Station.() -> Unit = { println("lambda: name=${name}") }
Station().show()
./gradlew copyData --configuration-cache --warning-mode all
Calculating task graph as no cached configuration is available for tasks: copyData
> Configure project :
project : io.tidepool:lab:1.0.1
getter : 1.0.1 (same as project.version)
file() : stations.csv
closure: name=San Francisco
inside the block: name=copyData project.name=lab prefix=tidepool
> Task :copyData
BUILD SUCCESSFUL in 218ms
1 actionable task: 1 executed
Configuration cache entry stored.
The listing is the Groovy run (blank lines removed). The Kotlin run printed the same lines, except that the getter line ends with (same as project.getVersion()) and the fourth line starts with
lambda: instead of closure:, which are the two literal strings that differ in the scripts; both builds finished with Configuration cache entry stored. and no deprecation warning, and both are compatible with the
configuration cache.
build/copied/stations.csv was the only file copied by include '**/*.csv'. The delegation line shows the thing to remember: the same word means different things at
different levels. A first draft of this example printed ${group} inside a task block and got null, because group inside the block is Task.group, not Project.group; qualify with
project. when you mean the project.
The Kotlin differences are visible in the pair: a Kotlin block is a lambda with receiver, so this is the delegate and there is no resolveStrategy; parentheses are mandatory;
def becomes val or var; a property assignment always needs =. The block mechanism and its Kotlin counterpart are explained in
Type-Safe Builders and DSLs.
The manual advises against local variables at the root of a script used as pseudo project properties: nothing outside the script can read them and Gradle does not know them. Use them inside a narrow block, or use an extra property or a Gradle property when the value has to be shared. (Extra properties are covered on Kotlin DSL and on Settings & Build Scripts.)
Finding things in the API documentation
Two references describe what a Groovy script can say. The Groovy DSL reference at docs.gradle.org/current/dsl lists every build-script block, the
types (Project, Task, Copy, Test, Jar and the rest) with their properties and methods, and, for each plugin, the extension and convention properties that it adds to Project. The Javadoc
lists the same types as Java methods. For a property added by a non-core plugin, qualify it with project. (for example project.checkstyle) so that it is clear where it comes from. The Kotlin DSL
has its own reference at docs.gradle.org/current/kotlin-dsl.
Source: Gradle: Groovy DSL Reference
Groovy 4 in Gradle 9
Gradle 9.0.0 upgraded the embedded Groovy from 3.0.24 to 4.0.27, and 9.8.0 is at 4.0.33. The Groovy project describes the 3-to-4 language changes (removed legacy packages, a different module structure, a new parser, different bytecode output); the Gradle upgrade guide lists the ones that reach build scripts:
-
is-prefixed
Booleanproperties are no longer properties in Groovy. Groovy 4 follows the Java Bean rules strictly:Boolean isReady()(the wrapper type) is not a getter. Gradle still treated these as properties for its own purposes in 9.x (up-to-date checks), and will stop in Gradle 10. -
DELEGATE_FIRSTclosures prefer the delegate more often. The lookup in Groovy 3 was the delegate’sinvokeMethod, the owner’sinvokeMethod, the delegate’sinvokeMissingMethod, the owner’sinvokeMissingMethod; in Groovy 4 it is the delegate’sinvokeMethod, the delegate’sinvokeMissingMethod, the owner’sinvokeMethod, the owner’sinvokeMissingMethod. Gradle’s API does not useDELEGATE_FIRSTclosures with dynamic properties, so build scripts are rarely affected;@CompileStaticor an explicitowner.,this.orsuper.qualification are the workarounds. -
Private properties and methods may be inaccessible in closures defined in a parent class and run in a subclass (a Groovy bug that stays until Groovy 5; the workaround is
@CompileStatic). -
Super methods may be inaccessible from Groovy 3.x code: only plugins compiled with Groovy 3 against Gradle 8.x and earlier are affected.
-
Groovy modules removed from the distribution:
groovy-test,groovy-consoleandgroovy-sqlare no longer on the classpath or inlocalGroovy(); declare them as dependencies when a build script or plugin needs them. -
Plugins written in Groovy must use Groovy 4.x to work with Gradle and Groovy DSL scripts. Plugins authored with the Groovy DSL and built with Gradle 9.x need Gradle 7.0 or newer to run; built with 7.x or 8.x they stay compatible with Gradle 5.0 and above.
The first bullet reproduces in a few lines. The task class below uses the wrapper type in its is getter:
abstract class Flagged extends DefaultTask {
private Boolean ready = true
@Input
Boolean isReady() { ready } // Boolean with an is- prefix
void setReady(Boolean value) { ready = value }
@TaskAction
void run() { println "ready via getter method: ${isReady()}" }
}
tasks.register('flagged', Flagged) {
println "property syntax sees: ${it.ready}"
}
./gradlew flagged --warning-mode all
Declaring 'ready' as a property using an 'is-' method with a Boolean type on Flagged has been deprecated. Starting with Gradle 10, this property will no longer be treated like a property. The combination of method name and return type is not consistent with Java Bean property rules. Add a method named 'getReady' with the same behavior and mark the old one with @Deprecated, or change the type of 'Flagged.isReady' (and the setter) to 'boolean'. Consult the upgrading guide for further information: https://docs.gradle.org/9.8.1/userguide/upgrading_version_8.html#groovy_boolean_properties
...
* What went wrong:
A problem occurred configuring root project 'lab'.
> Could not create task ':flagged'.
> Cannot get the value of write-only property 'ready' for task ':flagged' of type Flagged.
...
BUILD FAILED in 707ms
(The same deprecation message was printed twice; the second repeated it with @ReplacedBy added to the advice.) Gradle warns about the declaration, and the Groovy 4 script cannot read the property any more: it is "write-only".
With a primitive boolean getter and setter the build passes without a message:
abstract class Flagged extends DefaultTask {
private boolean ready = true
@Input
boolean isReady() { ready } // primitive boolean: a real Java Bean property
void setReady(boolean value) { ready = value }
@TaskAction
void run() { println "ready: ${ready}" }
}
tasks.register('flagged', Flagged) {
println "property syntax sees: ${it.ready}"
}
property syntax sees: true
> Task :flagged
ready: true
BUILD SUCCESSFUL in 1s
The fixed build is compatible with the configuration cache (the task reads only its own fields). The Kotlin DSL has no such rule, because Kotlin classes follow Kotlin’s own conventions. According to the 8.13 upgrade notes, Gradle 8.x (with Groovy 3) printed the deprecation but kept treating the getter as a property (not run here).
Source: Gradle: Upgrade to Groovy 4.0.27
Space assignment and implicit parent lookup
Two Groovy DSL idioms of older builds are deprecated and will break in Gradle 10. The first is space assignment: Gradle used to generate, for each property, a method of the same name, so group 'io.tidepool' and
version '1.0.0' assigned the properties even though that is not Groovy syntax. Since Gradle 8.12 that is deprecated; write group = 'io.tidepool' (a method that is declared explicitly, such as jvmArgs 'a', 'b',
is still a method call, and jvmArgs('a', 'b') is clearer). The second is the implicit lookup of properties in parent projects: a bare commonsCsvVersion in a child project resolving to an ext property in the root
project, deprecated in Gradle 9.6 and removed in Gradle 10. The classic tidepool build at the start of the migration below triggers both:
Build file '<work>/stage0/build.gradle': line 7
Properties should be assigned using the 'propName = value' syntax. Setting a property via the Gradle-generated 'propName value' or 'propName(value)' syntax in Groovy DSL has been deprecated. This is scheduled to be removed in Gradle 10. Use assignment ('group = <value>') instead. ...
Build file '<work>/stage0/build.gradle': line 43
Implicit lookup of properties in parent projects has been deprecated. This will fail with an error in Gradle 10. Property 'commonsCsvVersion' was not declared in project ':tidepool-engine' and was resolved from root project 'tidepool'. ...
(Trimmed to the first sentences of two of the four warnings; each real message ends with a link to the upgrade guide.) The migration below fixes both.
Migrating the tidepool build from Groovy to Kotlin
The manual describes two strategies. A mechanical migration moves the syntax to Kotlin bit by bit and keeps the structure; it is enough for a simple build. A restructuring migration also moves the build towards
the Gradle best practices (the plugins {} block, build-logic convention plugins, a version catalog), which is where the Kotlin DSL pays off most. Both are viable, and the manual recommends eventually applying
the best practices to every project. Because a build can mix both DSLs and a Kotlin script can apply a Groovy one, the migration can be done script by script without blocking the team. The manual’s checklist
before starting:
-
use the latest versions of Gradle, the plugins and the IDE;
-
in IntelliJ IDEA, import the project from the Gradle model; other IDEs import Kotlin DSL builds but do not edit them well (see the IDE table on Kotlin DSL);
-
expect slower first use on clean checkouts and CI agents, and a slower build after a
buildSrcchange; -
adopt the
plugins {}block in the Groovy scripts first, because it is what gives the Kotlin DSL its accessors; -
the Kotlin DSL does not support the
model {}block of the discontinued software model.
Step 0: the starting build
The starting point is a three-module tidepool build written the way many Groovy builds are: ext for versions, allprojects and subprojects blocks for cross-project configuration, apply plugin:, single quotes, no
parentheses, and the eager task statement. It is deliberately a good citizen of Gradle 9 apart from those idioms (it uses implementation, not compile).
rootProject.name = 'tidepool'
include 'tidepool-model', 'tidepool-engine', 'tidepool-cli'
ext {
commonsCsvVersion = '1.14.1'
picocliVersion = '4.7.7'
}
allprojects {
group 'io.tidepool'
version '1.0.0'
repositories {
mavenCentral()
}
}
subprojects {
apply plugin: 'java-library'
java {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
}
}
tasks.withType(JavaCompile) {
options.encoding = 'UTF-8'
}
test {
useJUnitPlatform()
}
jar {
manifest {
attributes 'Implementation-Title': project.name,
'Implementation-Version': project.version
}
}
}
project(':tidepool-engine') {
dependencies {
api project(':tidepool-model')
implementation "org.apache.commons:commons-csv:$commonsCsvVersion"
}
}
project(':tidepool-cli') {
apply plugin: 'application'
dependencies {
implementation project(':tidepool-engine')
implementation "info.picocli:picocli:$picocliVersion"
}
application {
mainClass = 'io.tidepool.cli.Main'
}
}
task stationCount {
doLast {
def lines = file('data/stations.csv').readLines()
println "stations: ${lines.size() - 1}"
}
}
task zipData(type: Zip) {
from 'data'
archiveFileName = 'data.zip'
destinationDirectory = file("$buildDir/dist")
}
It builds on 9.8.1: ./gradlew build stationCount zipData ended with BUILD SUCCESSFUL, 11 actionable tasks: 11 executed, and the line Deprecated Gradle features were used in this build, making it incompatible with Gradle 10.
--warning-mode all listed four: two space assignments (group, version) and two implicit parent lookups (commonsCsvVersion, picocliVersion, shown above). Run with the configuration cache, the stationCount task fails, because
file(...) is called inside doLast and so reaches the script object at execution time:
./gradlew stationCount --configuration-cache
Calculating task graph as no cached configuration is available for tasks: stationCount
> Task :stationCount FAILED
1 problem was found storing the configuration cache.
- Task `:stationCount` of type `org.gradle.api.DefaultTask`: invocation of 'file' references a Gradle script object from a Groovy closure at execution time, which is unsupported with the configuration cache.
See https://docs.gradle.org/9.8.1/userguide/configuration_cache_requirements.html#config_cache:requirements:gradle_model_types
...
BUILD FAILED in 459ms
...
Configuration cache entry discarded with 1 problem.
This build is this page’s own setup for the migration, not an example taken from the manual.
Step 1: prepare the Groovy script
The manual’s first step is to make the Groovy script look like Kotlin while it is still Groovy, so that the rename in the next step is mechanical. It names three differences: Groovy strings may use single or double quotes and Kotlin
needs double quotes; Groovy allows leaving out parentheses on calls and Kotlin always requires them; Groovy allows leaving out = when assigning (space assignment) and Kotlin always needs it. The suggested approach: replace '
with " (a search-and-replace), then turn every ambiguous statement into a property assignment first, and change the ones that fail into function calls. In the manual’s example, group "com.acme" becomes group = "com.acme" (a
property assignment) and implementation "com.acme:example:1.0" becomes implementation("com.acme:example:1.0") (a function call). Watch for the double-quote traps: a string with $ is now a GString in both languages
(Kotlin string templates use the same $name and ${expr}, which makes the two compatible), and a Groovy string that contained a literal $ or a " needs escaping.
Step 1 of tidepool also fixes the two deprecations of Step 0 (rootProject.ext.commonsCsvVersion is explicit) and the configuration cache problem (the file(...) call happens at configuration time and doLast only reads the local variable):
ext {
commonsCsvVersion = "1.14.1"
picocliVersion = "4.7.7"
}
allprojects {
group = "io.tidepool"
version = "1.0.0"
repositories {
mavenCentral()
}
}
subprojects {
apply(plugin: "java-library")
java {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
}
}
tasks.withType(JavaCompile) {
options.encoding = "UTF-8"
}
test {
useJUnitPlatform()
}
jar {
manifest {
attributes("Implementation-Title": project.name,
"Implementation-Version": project.version)
}
}
}
project(":tidepool-engine") {
dependencies {
api(project(":tidepool-model"))
implementation("org.apache.commons:commons-csv:${rootProject.ext.commonsCsvVersion}")
}
}
project(":tidepool-cli") {
apply(plugin: "application")
dependencies {
implementation(project(":tidepool-engine"))
implementation("info.picocli:picocli:${rootProject.ext.picocliVersion}")
}
application {
mainClass = "io.tidepool.cli.Main"
}
}
tasks.register("stationCount") {
def stations = file("data/stations.csv")
doLast {
def lines = stations.readLines()
println("stations: ${lines.size() - 1}")
}
}
tasks.register("zipData", Zip) {
from("data")
archiveFileName = "data.zip"
destinationDirectory = layout.buildDirectory.dir("dist")
}
./gradlew build stationCount zipData --warning-mode all --configuration-cache
Calculating task graph as no cached configuration is available for tasks: build stationCount zipData
stations: 3
BUILD SUCCESSFUL in 1s
11 actionable tasks: 11 executed
Configuration cache entry stored.
(Task lines removed.) No deprecation warning was printed, and the build is compatible with the configuration cache. The step also replaced the eager task x { } statements with tasks.register, a change that is
safe in Groovy and that the Kotlin DSL expects. The prepared script uses only syntax that Gradle 8.14 also accepts (not run here).
Step 2: rename to .kts and translate
Rename build.gradle to build.gradle.kts and settings.gradle to settings.gradle.kts (the settings script may be renamed on its own; you can have Groovy and Kotlin projects in one multi-project
build). The translation below is mechanical: it keeps the subprojects {} and project(...) {} structure, which means the accessors are unavailable inside them (the plugin is applied with apply(plugin = ...)), so
the string forms and configure<T> from Kotlin DSL are needed. Extra properties change from ext to extra, and ext.commonsCsvVersion reads become
extra["commonsCsvVersion"].
rootProject.name = "tidepool"
include("tidepool-model", "tidepool-engine", "tidepool-cli")
extra["commonsCsvVersion"] = "1.14.1"
extra["picocliVersion"] = "4.7.7"
allprojects {
group = "io.tidepool"
version = "1.0.0"
repositories {
mavenCentral()
}
}
subprojects {
apply(plugin = "java-library")
configure<JavaPluginExtension> {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
}
}
tasks.withType<JavaCompile> {
options.encoding = "UTF-8"
}
tasks.named<Test>("test") {
useJUnitPlatform()
}
tasks.named<Jar>("jar") {
manifest {
attributes(
"Implementation-Title" to project.name,
"Implementation-Version" to project.version
)
}
}
}
project(":tidepool-engine") {
dependencies {
"api"(project(":tidepool-model"))
"implementation"("org.apache.commons:commons-csv:${rootProject.extra["commonsCsvVersion"]}")
}
}
project(":tidepool-cli") {
apply(plugin = "application")
dependencies {
"implementation"(project(":tidepool-engine"))
"implementation"("info.picocli:picocli:${rootProject.extra["picocliVersion"]}")
}
configure<JavaApplication> {
mainClass = "io.tidepool.cli.Main"
}
}
tasks.register("stationCount") {
val stations = file("data/stations.csv")
doLast {
val lines = stations.readLines()
println("stations: ${lines.size - 1}")
}
}
tasks.register<Zip>("zipData") {
from("data")
archiveFileName = "data.zip"
destinationDirectory = layout.buildDirectory.dir("dist")
}
./gradlew build stationCount zipData --warning-mode all --configuration-cache
Calculating task graph as no cached configuration is available for tasks: build stationCount zipData
stations: 3
BUILD SUCCESSFUL in 1s
11 actionable tasks: 11 executed
Configuration cache entry stored.
It built the first time, produced the same result, and is compatible with the configuration cache. The manual’s own statement that tasks are configured lazily in the Kotlin DSL applies here: tasks.named<Jar>("jar") and
tasks.register<Zip>("zipData") defer the blocks. The strict equivalent of the Groovy tasks.withType(JavaCompile) { ... } is tasks.withType<JavaCompile> { ... }, which is eager; the better form is .configureEach
(see Kotlin DSL).
Step 3: restructure towards the best practices
The mechanical Kotlin build works, but all the string literals and configure<T> calls are the price of cross-project configuration. The restructured form puts the shared conventions into a build-logic included build
(a precompiled script plugin, which has accessors), the versions into a version catalog, and the repositories into settings.gradle.kts. That removes the two cross-project blocks and the ext properties. Each module’s
script becomes a few lines. Convention plugins are the subject of
Convention Plugins & Sharing Build Logic and catalogs of
Version Catalogs, so here is the result:
pluginManagement {
includeBuild("build-logic")
}
dependencyResolutionManagement {
repositories {
mavenCentral()
}
repositoriesMode = RepositoriesMode.FAIL_ON_PROJECT_REPOS
}
rootProject.name = "tidepool"
include("tidepool-model", "tidepool-engine", "tidepool-cli")
# gradle/libs.versions.toml
[versions]
commons-csv = "1.14.1"
picocli = "4.7.7"
[libraries]
commons-csv = { module = "org.apache.commons:commons-csv", version.ref = "commons-csv" }
picocli = { module = "info.picocli:picocli", version.ref = "picocli" }
plugins {
`java-library`
}
group = "io.tidepool"
version = "1.0.0"
java {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
}
}
tasks.withType<JavaCompile>().configureEach {
options.encoding = "UTF-8"
}
tasks.test {
useJUnitPlatform()
}
tasks.jar {
manifest {
attributes(
"Implementation-Title" to project.name,
"Implementation-Version" to project.version
)
}
}
The build-logic build has settings.gradle.kts with rootProject.name = "build-logic" and build.gradle.kts with plugins { `kotlin-dsl` } and repositories { mavenCentral() }, as in
Kotlin DSL. The modules and the root script:
// tidepool-model/build.gradle.kts
plugins {
id("tidepool.java-conventions")
}
// tidepool-engine/build.gradle.kts
plugins {
id("tidepool.java-conventions")
}
dependencies {
api(project(":tidepool-model"))
implementation(libs.commons.csv)
}
// tidepool-cli/build.gradle.kts
plugins {
id("tidepool.java-conventions")
application
}
dependencies {
implementation(project(":tidepool-engine"))
implementation(libs.picocli)
}
application {
mainClass = "io.tidepool.cli.Main"
}
tasks.register("stationCount") {
val stations = file("data/stations.csv")
doLast {
val lines = stations.readLines()
println("stations: ${lines.size - 1}")
}
}
tasks.register<Zip>("zipData") {
from("data")
archiveFileName = "data.zip"
destinationDirectory = layout.buildDirectory.dir("dist")
}
./gradlew build stationCount zipData --warning-mode all --configuration-cache
Calculating task graph as no cached configuration is available for tasks: build stationCount zipData
stations: 3
BUILD SUCCESSFUL in 16s
20 actionable tasks: 20 executed
Configuration cache entry stored.
(The 16 seconds include compiling build-logic and starting a new daemon; the 20 tasks include those of the build-logic build.) The build is compatible with the configuration cache and prints no deprecation warning. The accessors
now work everywhere (implementation(libs.picocli), application { ... }, tasks.jar { ... }), the cross-project subprojects {} block and the parent-project lookups are gone, and the ext block became a catalog.
Is it the same build?
The check that matters after each step is that the build produced the same artifacts. A small fingerprint was taken from each of the four steps after ./gradlew clean build zipData: the jar files, the Implementation-* lines of
the manifest of the tidepool-cli jar, the runtime classpath of tidepool-cli, and the entries of build/dist/data.zip:
ls tidepool-*/build/libs/*.jar
unzip -p tidepool-cli/build/libs/tidepool-cli-1.0.0.jar META-INF/MANIFEST.MF | tr -d '\r' | grep Implementation
./gradlew -q :tidepool-cli:dependencies --configuration runtimeClasspath | grep -E '^(\+|\\|\|)'
unzip -Z1 build/dist/data.zip
tidepool-cli/build/libs/tidepool-cli-1.0.0.jar
tidepool-engine/build/libs/tidepool-engine-1.0.0.jar
tidepool-model/build/libs/tidepool-model-1.0.0.jar
Implementation-Title: tidepool-cli
Implementation-Version: 1.0.0
+--- project ':tidepool-engine'
| +--- project ':tidepool-model'
| \--- org.apache.commons:commons-csv:1.14.1
| +--- commons-io:commons-io:2.20.0
| \--- commons-codec:commons-codec:1.19.0
\--- info.picocli:picocli:4.7.7
stations.csv
The fingerprints of Step 0, Step 1 and Step 2 were byte-for-byte identical to the one of Step 3 (diff printed nothing), which is the proof that the four scripts describe the same build. The manual does not provide a
tool for the comparison; checking jars, manifest and classpath is a cheap substitute. A migration should also compare the dependency insight and test results where tests exist.
Source: Gradle: Migration strategies
Translating constructs
The manual walks through the translation of plugins, tasks and dependencies; the table below collects them. The pair of scripts after the table runs most rows and printed the same five lines in both languages. The $ escape was run in a separate small build; the quotes row is a language rule, and the Spring Boot plugins {}
row and the db configuration row come from the manual’s migration chapter and were not run.
| Groovy DSL | Kotlin DSL |
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
The pair that produced the table’s behaviour exercises extra properties, an excluded transitive dependency, a version forced by resolutionStrategy.eachDependency, a replaced source directory list, a version computed
from a property, tasks created in a loop, and a report task. The Groovy script:
plugins {
id 'java-library'
}
repositories {
mavenCentral()
}
ext.threshold = 3 // extra property
def stations = ['SF', 'BOS', 'HNL'] // local variable
dependencies {
implementation('org.apache.commons:commons-csv:1.14.1') {
exclude group: 'commons-codec', module: 'commons-codec'
}
}
configurations.configureEach {
resolutionStrategy.eachDependency { details ->
if (details.requested.group == 'commons-io') {
details.useVersion('2.19.0')
}
}
}
sourceSets {
main {
java {
srcDirs = ['src/main/java', 'src/gen/java']
}
}
}
version = project.hasProperty('release') ? '1.0.0' : '1.0.0-SNAPSHOT'
stations.each { id ->
tasks.register("station-${id}") {
description = "Prints station ${id}"
}
}
def srcDirNames = sourceSets.main.java.srcDirs*.name
def runtime = files(configurations.runtimeClasspath)
def limit = threshold
def releaseVersion = version
def stationTasks = tasks.names.findAll { it.startsWith('station-') }.sort()
tasks.register('report') {
doLast {
println "version=${releaseVersion}"
println "limit=${limit}"
println "srcDirs=${srcDirNames}"
println "runtime=${runtime.files*.name.sort()}"
println "tasks=${stationTasks}"
}
}
The Kotlin script differs in the places the table lists. Inside the report task block the values are copied into locals, because a task block is a lambda whose receiver is the task: extra there would be the task’s
extra properties, and doLast would capture the script object (the pitfalls of Kotlin DSL):
plugins {
`java-library`
}
repositories {
mavenCentral()
}
extra["threshold"] = 3 // extra property
val stations = listOf("SF", "BOS", "HNL") // local variable
dependencies {
implementation("org.apache.commons:commons-csv:1.14.1") {
exclude(group = "commons-codec", module = "commons-codec")
}
}
configurations.configureEach {
resolutionStrategy.eachDependency {
if (requested.group == "commons-io") {
useVersion("2.19.0")
}
}
}
sourceSets {
main {
java {
setSrcDirs(listOf("src/main/java", "src/gen/java"))
}
}
}
version = if (project.hasProperty("release")) "1.0.0" else "1.0.0-SNAPSHOT"
stations.forEach { id ->
tasks.register("station-$id") {
description = "Prints station $id"
}
}
tasks.register("report") {
// copy script-level values into locals: doLast must not capture the script object
val releaseVersion = project.version
val limit = project.extra["threshold"]
val srcDirNames = sourceSets.main.get().java.srcDirs.map { it.name }
val runtime = files(configurations.runtimeClasspath)
val stationTasks = tasks.names.filter { it.startsWith("station-") }.sorted()
doLast {
println("version=$releaseVersion")
println("limit=$limit")
println("srcDirs=$srcDirNames")
println("runtime=${runtime.files.map { it.name }.sorted()}")
println("tasks=$stationTasks")
}
}
./gradlew report -q --configuration-cache
version=1.0.0-SNAPSHOT
limit=3
srcDirs=[java, java]
runtime=[commons-csv-1.14.1.jar, commons-io-2.19.0.jar]
tasks=[station-BOS, station-HNL, station-SF]
Both scripts printed exactly these lines (diff of the two outputs was empty), and both reused the configuration cache entry on the second run. commons-codec is gone from the runtime classpath (the exclusion), commons-io is 2.19.0
and not 2.20.0 (the forced version), and srcDirs lists the two java directories (src/main/java and src/gen/java). The first attempt at the Kotlin script failed the configuration cache check (cannot serialize Gradle script object references) and then the extra-properties lookup;
those are the problems in the Kotlin page’s table.
Mixing the two DSLs
Because the extension decides the language, a build can be half Groovy and half Kotlin. The migration of a large build is therefore a sequence of small commits: a project, a script, a convention plugin at a time. The variant below takes the Step 3 build and converts the settings file and one module back to Groovy, with the convention plugin, the catalog and the other modules unchanged:
pluginManagement {
includeBuild 'build-logic'
}
dependencyResolutionManagement {
repositories {
mavenCentral()
}
repositoriesMode = RepositoriesMode.FAIL_ON_PROJECT_REPOS
}
rootProject.name = 'tidepool'
include 'tidepool-model', 'tidepool-engine', 'tidepool-cli'
plugins {
id 'tidepool.java-conventions'
id 'application'
}
dependencies {
implementation project(':tidepool-engine')
implementation libs.picocli
}
application {
mainClass = 'io.tidepool.cli.Main'
}
./gradlew build --warning-mode all --configuration-cache
Calculating task graph as no cached configuration is available for tasks: build
BUILD SUCCESSFUL in 4s
18 actionable tasks: 18 executed
Configuration cache entry stored.
The files that remained: settings.gradle and tidepool-cli/build.gradle (Groovy), and build.gradle.kts, tidepool-model/build.gradle.kts, tidepool-engine/build.gradle.kts and the whole build-logic build (Kotlin). The version
catalog libs.picocli works from both languages. A Kotlin script can also apply a Groovy one with apply(from = "legacy.gradle"), which is the escape hatch for a plugin whose configuration is too dynamic for Kotlin (see the interoperability section of
Kotlin DSL). Plugins and tasks written in Java, Groovy or Kotlin can be used from either DSL.
Source: Gradle: Interoperability
When to stay on Groovy
-
The build is stable, small and nobody edits it often: the migration buys little, and the first-run slowness of Kotlin script compilation is a real cost on ephemeral CI agents.
-
The build depends on plugins whose configuration relies on Groovy metaprogramming or
Closurearguments: each needsclosureOf,withGroovyBuilderor a Groovy script on the Kotlin side. -
The team edits builds in an IDE without semantic Kotlin DSL support (see the IDE table on Kotlin DSL).
Even then, the Groovy-side clean-up of Step 1 (double quotes, =, parentheses, tasks.register, plugins {}, no parent lookups, no Task.project at execution time) is worth doing: it removes the Gradle 10 warnings and
prepares the configuration cache, which Gradle 9.0 calls the preferred mode. In the three-tool picture, Maven has no equivalent migration, because the POM is XML; moving between tools is the subject of
Migrating Between Build Tools and the comparison in
Comparing Ant, Maven and Gradle.
Where the book is outdated
Gradle in Action (Gradle 1.7) uses the Groovy DSL throughout. Several idioms it teaches no longer exist or are discouraged; the list for scripts follows (from the Gradle release notes and upgrade guides):
| The book | Gradle 9.8.1 |
|---|---|
|
removed in 5.0; use |
|
removed in 7.0; |
|
the |
Groovy DSL only |
Kotlin DSL 1.0 (5.0), the default for |
|
convention plugins in |
versions in |
version catalogs (stable in 7.4) |
no configuration cache; |
the configuration cache is stable (8.1) and preferred (9.0); |
JDK 1.5+ to run; Groovy 1.8.6 |
JVM 17+ to run (9.0); Groovy 4 and Kotlin 2 embedded; older JDKs only through toolchains |
Classic problems
| Message (Gradle 9.8.1) | Cause | Fix |
|---|---|---|
|
space assignment, |
|
|
a child script used a root |
|
|
|
a primitive |
|
Groovy 4 no longer reads |
as above |
|
|
compute the value at configuration time into a local |
|
the plugin is applied with |
the |
|
inside the block, unqualified |
|
Summary
-
A Groovy build script runs against an implicit
Project; a block is a method whose last argument is anActionor aClosure, and its delegate decides where unqualified names are found. -
Gradle 9 embeds Groovy 4:
Boolean isX()is no longer a property, space assignment and implicit parent lookup are deprecated, andTask.projectat execution time breaks the configuration cache. -
Migrate in steps: prepare the Groovy (double quotes,
=, parentheses,plugins {}), rename to.kts, then restructure into convention plugins and a version catalog; compare the produced artifacts after each step. -
Mixed DSLs are normal: the file extension decides, and a Kotlin script can apply a Groovy one.
-
Do the Step 1 clean-up even if you stay on Groovy.