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

<obj>.<name>

get a property value

<obj>.<name> = <value>

set a property to a new value

"$<name>" and "${<obj>.<name>}"

embed a value in a string (a GString)

<obj>.<name>() and <obj>.<name>(<arg>, <arg>)

a method call; with <obj>.<name> <arg>, <arg> the parentheses can be left out

<obj>.<name> { ... } and <obj>.<name>(<arg>) { ... }

a block: a method whose last argument is an Action or a Closure

def <name> = <value> and <type> <name> = <value>

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:

Groovy DSL
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()
Kotlin DSL
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.

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 Boolean properties 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_FIRST closures prefer the delegate more often. The lookup in Groovy 3 was the delegate’s invokeMethod, the owner’s invokeMethod, the delegate’s invokeMissingMethod, the owner’s invokeMissingMethod; in Groovy 4 it is the delegate’s invokeMethod, the delegate’s invokeMissingMethod, the owner’s invokeMethod, the owner’s invokeMissingMethod. Gradle’s API does not use DELEGATE_FIRST closures with dynamic properties, so build scripts are rarely affected; @CompileStatic or an explicit owner., this. or super. 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-console and groovy-sql are no longer on the classpath or in localGroovy(); 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:

Groovy DSL (is-prefixed Boolean 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:

Groovy DSL (fixed)
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).

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 buildSrc change;

  • 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).

Groovy DSL (settings.gradle)
rootProject.name = 'tidepool'
include 'tidepool-model', 'tidepool-engine', 'tidepool-cli'
Groovy DSL (build.gradle)
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):

Groovy DSL (build.gradle, prepared)
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"].

Kotlin DSL (settings.gradle.kts)
rootProject.name = "tidepool"
include("tidepool-model", "tidepool-engine", "tidepool-cli")
Kotlin DSL (build.gradle.kts, mechanical)
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:

Kotlin DSL (settings.gradle.kts)
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" }
Kotlin DSL (build-logic/src/main/kotlin/tidepool.java-conventions.gradle.kts)
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:

Kotlin DSL (tidepool-model/build.gradle.kts, tidepool-engine/build.gradle.kts, tidepool-cli/build.gradle.kts)
// 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"
}
Kotlin DSL (build.gradle.kts, root)
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.

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

'single' or "double" quotes

"double" only; a literal $ is written \$ in both (Kotlin also accepts ${'$'}), and both printed $5 and $6

implementation 'g:a:v'

implementation("g:a:v")

apply plugin: 'java'

plugins { java } (preferred) or apply(plugin = "java"); then no accessors

plugins { id 'org.springframework.boot' version '3.4.4' }

plugins { id("org.springframework.boot") version "3.4.4" }

ext.threshold = 3 and ext.threshold

extra["threshold"] = 3 and extra["threshold"] as Int

def x = 1

val x = 1 (or var)

task hello { }

tasks.register("hello") { } (the eager task("hello") { } exists but is not lazy)

task zip(type: Zip) { }

tasks.register<Zip>("zip") { }

tasks.withType(JavaCompile) { }

tasks.withType<JavaCompile>().configureEach { }

tasks.named('test') { }

tasks.named<Test>("test") { } or the accessor tasks.test { }

jar { manifest { attributes 'k': 'v' } }

tasks.jar { manifest { attributes("k" to "v") } }

exclude group: 'g', module: 'm'

exclude(group = "g", module = "m")

sourceSets { main { java { srcDirs = ['a', 'b'] } } }

sourceSets { main { java { setSrcDirs(listOf("a", "b")) } } }

list.each { id -> ... }

list.forEach { id -> ... }

project.hasProperty('release') ? 'a' : 'b'

if (project.hasProperty("release")) "a" else "b"

configurations { db; integTestImplementation { extendsFrom testImplementation } }

val db = configurations.create("db") and val integTestImplementation = configurations.create("integTestImplementation") { extendsFrom(configurations["testImplementation"]) }

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:

Groovy DSL
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):

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:

Groovy DSL (settings.gradle)
pluginManagement {
    includeBuild 'build-logic'
}

dependencyResolutionManagement {
    repositories {
        mavenCentral()
    }
    repositoriesMode = RepositoriesMode.FAIL_ON_PROJECT_REPOS
}

rootProject.name = 'tidepool'

include 'tidepool-model', 'tidepool-engine', 'tidepool-cli'
Groovy DSL (tidepool-cli/build.gradle)
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.

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 Closure arguments: each needs closureOf, withGroovyBuilder or 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

task name << { }

removed in 5.0; use doLast { } with tasks.register

compile, runtime, testCompile, testRuntime

removed in 7.0; implementation, api, compileOnly, runtimeOnly, testImplementation, testRuntimeOnly

apply plugin: 'x' only

the plugins {} block and the Plugin Portal

Groovy DSL only

Kotlin DSL 1.0 (5.0), the default for gradle init since 8.2

allprojects / subprojects configuration injection

convention plugins in buildSrc or build-logic

versions in ext properties

version catalogs (stable in 7.4)

no configuration cache; project used freely at execution time

the configuration cache is stable (8.1) and preferred (9.0); Task.project at execution time is deprecated

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

Properties should be assigned using the 'propName = value' syntax.

space assignment, group 'x'

group = 'x'

Implicit lookup of properties in parent projects has been deprecated.

a child script used a root ext property by its bare name

rootProject.ext.name, providers.gradleProperty, or a convention plugin

Declaring 'ready' as a property using an 'is-' method with a Boolean type

Boolean isReady()

a primitive boolean, or getReady()

Cannot get the value of write-only property 'ready'

Groovy 4 no longer reads Boolean isReady() as a property

as above

invocation of 'file' references a Gradle script object from a Groovy closure at execution time

file(...), project.x or a script method called inside doLast

compute the value at configuration time into a local def

Unresolved reference 'implementation' after renaming to .kts

the plugin is applied with apply plugin: or in subprojects {}

the plugins {} block, or "implementation"(...)

${group} prints null inside a task block

inside the block, unqualified group is Task.group

project.group

Summary

  • A Groovy build script runs against an implicit Project; a block is a method whose last argument is an Action or a Closure, 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, and Task.project at 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.