Kotlin DSL
|
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 Kotlin 2.4.10 and compiles Kotlin DSL scripts with Kotlin language
version 2.2. Every example was run through the 9.8.1 wrapper on 2026-10-16, on macOS, with a JDK 26 daemon, and 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. Each example
was run with --configuration-cache unless the text says it demonstrates a problem, and says whether it is compatible. Gradle 8.14 was not available
here: what the text says about 8.14 comes from the release notes and the user manual and is labelled "not run here". The scratch directory is
shown as <work>.
The Kotlin DSL is Gradle’s second way to write build scripts: the same Gradle API as the Groovy DSL, but in a statically typed language. The
Gradle best practices recommend it for new builds, and gradle init has defaulted to it since 8.2
(see Installation). The Groovy side is on
Groovy DSL & Migrating to Kotlin; what a settings file and a
build file contain is on Settings & Build Scripts. The language itself is
covered in the Kotlin section, where Build and Tooling shows how a Kotlin project uses it and
Type-Safe Builders and DSLs explains the lambda-with-receiver mechanism that
every plugins {} or dependencies {} block is built on.
Source: Gradle: Use Kotlin DSL
What the Kotlin DSL is, and how it got here
A Kotlin DSL script is Kotlin code that Gradle compiles with an embedded Kotlin compiler during the configuration phase and then runs against an
implicit receiver: a Project for build.gradle.kts, a Settings for settings.gradle.kts, a Gradle for an init script. There is no separate
language: plugins {}, dependencies {} and tasks.register("x") { ... } are ordinary function calls whose last argument is a lambda with a
receiver. This is the difference from the Groovy DSL, where the same blocks are method calls on a dynamic object that Gradle decorates at run time.
| Release | Change for the Kotlin DSL |
|---|---|
3.0 (2016-08-15) |
first support for Gradle Script Kotlin, a Kotlin-based build language (Groovy stays the primary one) |
5.0 (2018-11-26) |
Kotlin DSL 1.0, "production-ready" in the release notes; accessors for configurations became |
8.1 (2023-04-12) |
|
8.2 (2023-06-30) |
|
8.12 to 8.14 |
Kotlin 2.0.21 embedded, still with language version 1.8 (not run here) |
9.0.0 (2025-07-31) |
Kotlin 2.2 embedded and language version 2.2 for scripts and Kotlin build logic; much better script compilation avoidance; JSpecify nullability annotations in the API; |
9.6.0 (2026-06-18) |
the |
9.7.0 (2026-08-06) |
scripts are compiled against a prebuilt public API jar, so |
9.8.0 (2026-09-24) |
Kotlin 2.4.10 embedded (language version still 2.2) |
Sources: Gradle 5.0 release notes, Gradle 8.2 release notes, Gradle: Upgrade to Kotlin 2.2.0, and Gradle: Deprecation of Kotlin DSL delegated properties
The best-practices page says the Kotlin DSL has been the default for gradle init "since Gradle 8.0", while the Gradle 8.2 release notes say that
the init task switched to it in 8.2; this page follows the release notes. In the three-tool picture, Ant and Maven scripts are XML, and neither
has a typed language for the build file: Maven offers other syntaxes only through the Polyglot extension described on
Maven Extensions, and Ant embeds scripting languages inside targets (see
Scripting in Ant).
The tidepool build in both DSLs
The tidepool build used on all the Gradle pages has three modules. The command-line module in both DSLs, with a Java 21 toolchain:
plugins {
application
}
java {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
}
}
dependencies {
implementation(project(":tidepool-engine"))
implementation("info.picocli:picocli:4.7.7")
}
application {
mainClass = "io.tidepool.cli.Main"
}
plugins {
id 'application'
}
java {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
}
}
dependencies {
implementation project(':tidepool-engine')
implementation 'info.picocli:picocli:4.7.7'
}
application {
mainClass = 'io.tidepool.cli.Main'
}
Both builds use settings.gradle.kts or settings.gradle with rootProject.name = "tidepool", include("tidepool-model", "tidepool-engine", "tidepool-cli")
and a dependencyResolutionManagement block that declares Maven Central once. With the Kotlin DSL build:
./gradlew :tidepool-cli:build --configuration-cache
Calculating task graph as no cached configuration is available for tasks: :tidepool-cli:build
> Task :tidepool-model:compileJava
> Task :tidepool-model:jar
> Task :tidepool-engine:compileJava
> Task :tidepool-engine:jar
> Task :tidepool-cli:compileJava
> Task :tidepool-cli:jar
> Task :tidepool-cli:startScripts
> Task :tidepool-cli:distTar
> Task :tidepool-cli:distZip
> Task :tidepool-cli:assemble
> Task :tidepool-cli:build
BUILD SUCCESSFUL in 654ms
9 actionable tasks: 9 executed
Configuration cache entry stored.
The listing keeps only the tasks that did work (the NO-SOURCE and UP-TO-DATE lines were left out). The Groovy DSL build ran the same nine tasks
and also ended with BUILD SUCCESSFUL and Configuration cache entry stored. Both are compatible with the configuration cache. A second run printed
Configuration cache entry reused.
Source: Gradle: Kotlin DSL scripts
Script file names and implicit imports
Kotlin DSL scripts use the extension *.gradle.kts; Groovy DSL scripts use *.gradle. The extension is the switch: build.gradle.kts, settings.gradle.kts
and init scripts activate the Kotlin DSL, and one build may mix both. A Kotlin build script can apply a Groovy one and the other way round,
and different projects of a multi-project build can use either. For the IDE to know which object backs a script, the manual recommends
the pattern *.settings.gradle.kts for scripts applied to Settings (including script plugins applied from a settings script) and
*.init.gradle.kts or just init.gradle.kts for init scripts. Mixing DSLs is shown in
Groovy DSL & Migrating to Kotlin.
Every Kotlin DSL script starts with implicit imports: the default Gradle API imports, the Kotlin DSL API (the packages org.gradle.kotlin.dsl,
org.gradle.kotlin.dsl.plugins.dsl and org.gradle.kotlin.dsl.precompile), and java.util.concurrent.Callable, java.util.concurrent.TimeUnit,
java.math.BigDecimal, java.math.BigInteger, java.io.File and javax.inject.Inject. The manual warns against using internal Kotlin DSL APIs, because
they break when Gradle or a plugin is updated. The script below has no import line and still compiles:
// no import statements: these types come from the implicit imports
val millis = TimeUnit.SECONDS.toMillis(2)
val sum = BigDecimal("0.1").add(BigDecimal("0.2"))
val dirName = File(projectDir, "src").name
tasks.register("imports") {
val message = "kotlin=${KotlinVersion.CURRENT} millis=$millis sum=$sum dir=$dirName java=${JavaVersion.current()}"
doLast { println(message) }
}
./gradlew imports -q --configuration-cache
kotlin=2.4.10 millis=2000 sum=0.3 dir=src java=26
KotlinVersion.CURRENT shows the embedded compiler, 2.4.10. The example is compatible with the configuration cache because doLast captures only
the string message. The Groovy DSL has the same default imports for the Gradle API (and Groovy’s own java.io, java.util and java.math auto-imports), so
there is nothing to compare.
Source: Gradle: Implicit imports
Type-safe model accessors
In the Groovy DSL, configurations.implementation or java { ... } are resolved by name at run time, even though the configuration or extension only
exists because a plugin was applied. The Kotlin DSL replaces that dynamic resolution with type-safe model accessors: Kotlin source that Gradle generates for
each project script, after the plugins {} block has run, from the model that the plugins contributed. This is why a typo in implementaton("...") is a
compile error and why an IDE can complete java.toolchain.languageVersion.
What gets an accessor
For the project build script (and precompiled project script plugins) the manual lists these sets:
| Accessor for | Example (Java plugin) |
|---|---|
dependency and artifact configurations |
|
project extensions and conventions, and extensions on them |
|
extensions of the |
|
elements of the |
|
elements of project-extension containers |
|
For a settings script (and precompiled settings plugins) the accessors are the extensions contributed by settings plugins, such as
pluginManagement and dependencyResolutionManagement. Init scripts and script plugins applied with apply(from = ...) have no accessors. The
set is fixed right after the plugins {} block, which gives three rules:
-
the plugin must be applied in the
plugins {}block of the same script (or a precompiled script plugin); -
a configuration, task or extension that the script itself creates later has no accessor;
-
a plugin applied from the outside (
apply(plugin = ...), asubprojects {}orallprojects {}block in the root script, a script plugin) gives no accessor either.
The accessor report
kotlinDslAccessorsReport prints the Kotlin source of every accessor that the applied plugins contribute: the name, the type and the code that
Gradle generated. For tidepool-cli the report is 3 635 lines long, so the example filters it for the accessors used in the build file above:
./gradlew :tidepool-cli:kotlinDslAccessorsReport -q | grep -E 'val .*`(java|application|compileJava|implementation)`'
val org.gradle.api.Project.`java`: org.gradle.api.plugins.JavaPluginExtension get() =
val org.gradle.api.Project.`application`: org.gradle.api.plugins.JavaApplication get() =
val TaskContainer.`compileJava`: TaskProvider<org.gradle.api.tasks.compile.JavaCompile>
val org.gradle.api.NamedDomainObjectContainer<org.gradle.api.artifacts.Configuration>.`implementation`: NamedDomainObjectProvider<org.gradle.api.artifacts.Configuration>
java is a JavaPluginExtension on Project, compileJava is a TaskProvider<JavaCompile>, and implementation is a
NamedDomainObjectProvider<Configuration>, which is the lazy type that keeps the configuration-avoidance promise: an accessor never realizes the task or
configuration unless the script calls get(). (The report was run without --configuration-cache; it is a diagnostic task.)
When the accessor is missing
The usual way to lose accessors is apply(plugin = ...) in the script body. The script below is the tidepool engine build with that mistake, and
Gradle 9.8.1 fails at script compilation:
apply(plugin = "java-library")
repositories {
mavenCentral()
}
java {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
}
}
dependencies {
implementation("org.apache.commons:commons-csv:1.14.1")
}
> Configure project :
e: file://<work>/labs/apply-broken/build.gradle.kts:7:1: Unresolved reference. None of the following candidates is applicable because of a receiver type mismatch:
val PluginDependenciesSpec.java: PluginDependencySpec
e: file://<work>/labs/apply-broken/build.gradle.kts:8:5: Unresolved reference 'toolchain'.
e: file://<work>/labs/apply-broken/build.gradle.kts:9:9: Unresolved reference 'languageVersion'.
e: file://<work>/labs/apply-broken/build.gradle.kts:14:5: Unresolved reference 'implementation'.
FAILURE: Build failed with an exception.
...
The first error is a surprise: java resolves to the plugins {} helper PluginDependenciesSpec.java, because that is the only java the compiler can see. The
fix is to use the plugins {} block. When that cannot be done, the standard API replaces each accessor, using the type or the string name that
the accessor stood for:
apply(plugin = "java-library")
repositories {
mavenCentral()
}
configure<JavaPluginExtension> {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
}
}
dependencies {
"implementation"("org.apache.commons:commons-csv:1.14.1")
}
./gradlew dependencies --configuration compileClasspath -q --configuration-cache
------------------------------------------------------------
Root project 'lab'
------------------------------------------------------------
compileClasspath - Compile classpath for source set 'main'.
\--- org.apache.commons:commons-csv:1.14.1
+--- commons-io:commons-io:2.20.0
\--- commons-codec:commons-codec:1.19.0
A web-based, searchable dependency report is available by adding the --scan option.
| Without an accessor | Use |
|---|---|
a dependency configuration |
a string: |
a project extension |
|
an element of an extension container |
|
a task |
|
The second way to lose an accessor is to create the element after the plugins {} block, which the manual calls out with a custom configuration. In the
script below configurations.create("report") is not visible as report(...) in the dependencies block:
plugins {
`java-library`
}
configurations.create("report")
dependencies {
report("org.apache.commons:commons-csv:1.14.1")
}
> Configure project :
e: file://<work>/labs/late-config/build.gradle.kts:8:5: Unresolved reference 'report'.
FAILURE: Build failed with an exception.
...
Script compilation error:
Line 8: report("org.apache.commons:commons-csv:1.14.1")
^ Unresolved reference 'report'.
The remedy used in the Gradle manual’s migration chapter is a local variable bound to the new configuration: report(...) is then an invoke call on that
Configuration object inside the dependencies {} block. The script below does that, with a repositories {} block so that the dependency resolves, and prints the
configuration from a task. The doLast only reads two values that were copied into local variables, which keeps it compatible with the configuration cache
(a second run reused the cache entry):
plugins {
`java-library`
}
repositories {
mavenCentral()
}
val report = configurations.create("report")
dependencies {
report("org.apache.commons:commons-csv:1.14.1")
}
tasks.register("showReport") {
val name = report.name
val count = report.dependencies.size
doLast { println("configuration '$name' declares $count dependency") }
}
configuration 'report' declares 1 dependency
A plugin that is applied only in the parent project does not give the child project accessors. In a two-project build whose root script applies
java-library and whose child script has no plugins {} block, the child’s java { ... } failed with the same Unresolved reference errors (this
was run; the manual’s sentence about elements contributed by plugins applied by parent projects was not reproduced with a core plugin). Apply the plugin
where it is used, or move it into a convention plugin (see
Convention Plugins & Sharing Build Logic).
The Groovy DSL has no accessors: the same names are resolved dynamically, so the apply plugin: form and the late-created configuration work
without ceremony, and typos surface only when the line runs. That trade is the heart of the choice between the DSLs.
Working with containers
Configurations, tasks, source sets and the containers contributed by plugins (such as the buildTypes container of the Android plugin) are
NamedDomainObjectContainer values, and some are PolymorphicDomainObjectContainer. The Kotlin DSL gives each operation a lazy and an eager form:
| Operation | Lazy (preferred) | Eager |
|---|---|---|
find an existing element |
|
|
create a new element |
|
|
configure all of a type |
|
|
use an accessor |
|
|
The example below puts the forms side by side. Each block prints a line during configuration, so the output shows when the block runs. A reused
configuration cache entry skips configuration and would print nothing, so these runs used --no-configuration-cache; the same build with
--configuration-cache is compatible:
plugins {
java
}
// register: lazy, the block runs only when the task is needed
tasks.register("lazyHello") {
println("configuring lazyHello")
doLast { println("Hello from lazyHello") }
}
// create: eager, the block runs at once
tasks.create("eagerHello") {
println("configuring eagerHello")
doLast { println("Hello from eagerHello") }
}
// named: lazy reference to an existing task, with its type
tasks.named<JavaCompile>("compileJava") {
println("configuring compileJava")
options.encoding = "UTF-8"
}
// register returns a TaskProvider that can be wired into other tasks
val copyData = tasks.register<Copy>("copyData") {
println("configuring copyData")
from("data")
into(layout.buildDirectory.dir("copied"))
}
tasks.named("check") {
dependsOn(copyData)
}
// getByName: eager, realizes the task now
tasks.getByName<Test>("test") {
println("configuring test")
}
plugins {
id 'java'
}
// register: lazy, the block runs only when the task is needed
tasks.register('lazyHello') {
println 'configuring lazyHello'
doLast { println 'Hello from lazyHello' }
}
// create: eager, the block runs at once
tasks.create('eagerHello') {
println 'configuring eagerHello'
doLast { println 'Hello from eagerHello' }
}
// named: lazy reference to an existing task
tasks.named('compileJava', JavaCompile) {
println 'configuring compileJava'
options.encoding = 'UTF-8'
}
// register returns a TaskProvider that can be wired into other tasks
def copyData = tasks.register('copyData', Copy) {
println 'configuring copyData'
from 'data'
into layout.buildDirectory.dir('copied')
}
tasks.named('check') {
dependsOn copyData
}
// getByName: eager, realizes the task now
tasks.getByName('test') {
println 'configuring test'
}
./gradlew help -q --no-configuration-cache
./gradlew lazyHello -q --no-configuration-cache
./gradlew check -q --no-configuration-cache
configuring eagerHello
configuring test
configuring eagerHello
configuring test
configuring lazyHello
Hello from lazyHello
configuring eagerHello
configuring test
configuring copyData
configuring compileJava
help configures only the two eager tasks: eagerHello (from create) and test (from getByName). Asking for lazyHello realizes that task alone, and check
realizes copyData and compileJava, which are on its path, and never lazyHello. Both DSLs printed exactly the same lines in every run, and the
build was run with --warning-mode all without any deprecation warning. On a large build with hundreds of tasks, the difference between the
two columns is what task configuration avoidance is for (see
Tasks and
Lazy Configuration & Providers).
The withType form is a separate case: tasks.withType<JavaCompile> { println(...) } configured compileJava and compileTestJava while help ran, but the
tasks.withType<Test>().configureEach { ... } form configured test only when check was run, so prefer configureEach.
The tasks {} block groups several operations without repeating the container name, mixing accessors and the container API:
tasks {
test {
testLogging.showStackTraces = true
}
val myCheck = register("myCheck") {
doLast { println("myCheck ran") }
}
check {
dependsOn(myCheck)
}
}
tasks.named('test') {
testLogging.showStackTraces = true
}
def myCheck = tasks.register('myCheck') {
doLast { println 'myCheck ran' }
}
tasks.named('check') {
dependsOn myCheck
}
Running ./gradlew check -q --configuration-cache printed myCheck ran with both DSLs, and both are compatible with the configuration cache.
The by-delegates are deprecated since 9.6
Older Kotlin DSL builds, and the manual’s own Kotlin DSL chapter in earlier releases, bind a container element to a Kotlin val with a delegate:
val copyData by tasks.registering(Copy::class), val check by tasks.existing, val test by tasks.getting, and val release by extra("x"). The
element’s name is the name of the variable, which couples a Kotlin identifier to a task name. Gradle 9.6 deprecated all of these delegates; they will be
removed in Gradle 10. A build that uses three of them:
plugins {
java
}
val copyData by tasks.registering(Copy::class) {
from("data")
into(layout.buildDirectory.dir("copied"))
}
val check by tasks.existing {
dependsOn(copyData)
}
val release by extra("spring-tide")
./gradlew check --warning-mode all
> Configure project :
The 'val name by registering(Type::class) { }' property delegate syntax has been deprecated. This is scheduled to be removed in Gradle 10. Use 'val element = register<Type>(name) { }' instead.
at Build_gradle.<init>(build.gradle.kts:5)
The 'val name by existing { }' property delegate syntax has been deprecated. This is scheduled to be removed in Gradle 10. Use 'val element = named(name) { }' instead.
at Build_gradle.<init>(build.gradle.kts:10)
The 'val name by extra(...)' or 'val name by extra { ... }' property delegate syntax has been deprecated. This is scheduled to be removed in Gradle 10. Use 'extra.set(name, value)' instead.
at Build_gradle.<init>(build.gradle.kts:14)
...
BUILD SUCCESSFUL in 472ms
Each message in the real output continues with the sentence "Consult the upgrading guide for further information" and a link; those sentences were left out here. The upgrade guide gives the replacement for each delegate:
| Deprecated | Replacement |
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Besides the deprecation, the delegate hides a trap: the element name comes from the variable. In an earlier version of the example above, the line
val checked by tasks.existing failed at configuration with Task with name 'checked' not found in root project 'lab'. The explicit calls
(named("check")) have no such coupling.
The "name"() syntax and other removals in 9.0
Gradle 9.0.0 also removed the "name"() syntax that referenced a task or another domain object by a string at the top level of a script. A top-level
"compileJava"() { ... } no longer compiles:
plugins {
java
}
"compileJava"() {
options.encoding = "UTF-8"
}
> Configure project :
e: file://<work>/labs/namecall/build.gradle.kts:5:1: None of the following candidates is applicable:
fun <T : Any> Action<in T>.invoke(target: T): Unit:
...
* What went wrong:
Script compilation errors:
Line 5: "compileJava"() {
^ None of the following candidates is applicable:
...
Use tasks.named<JavaCompile>("compileJava") { ... }, or the accessor tasks.compileJava { ... }. The string form that survives is the invoke inside a
container block (dependencies { "implementation"(...) } ran without warnings on 9.8.1 in the examples on this page). The same release removed
the eager accessor extensions such as configurations.compileClasspath.files (write .get().files) and made a library or bundle of a version catalog in the
plugins {} block a build failure. In Gradle 8.14 the "name"() syntax still worked (not run here).
Properties: lazy assignment and extra properties
Assigning with =
Most task and extension inputs in the current API are lazy Property, ListProperty, MapProperty and ConfigurableFileCollection values. They are
read-only references with a set method, which in the Groovy DSL was always assignable with = and in Kotlin needed .set(...). Since Gradle 8.1 (opt-in) and
8.2 (default) the Kotlin DSL turns an assignment to a publicly final property of one of those types into a call that sets the value, so = is
the preferred way to call set(). The manual says this works for properties that have no public setter and are typed Property or
ConfigurableFileCollection; the example below also assigns a ListProperty and a RegularFileProperty and compiles on 9.8.1.
abstract class StationReport : DefaultTask() {
@get:Input
abstract val station: Property<String>
@get:Input
abstract val constituents: ListProperty<String>
@get:InputFiles
abstract val csv: ConfigurableFileCollection
@get:OutputFile
abstract val report: RegularFileProperty
@TaskAction
fun write() {
report.get().asFile.writeText(
"station=${station.get()}\nconstituents=${constituents.get()}\ncsv=${csv.files.map { it.name }}\n"
)
}
}
tasks.register<StationReport>("stationReport") {
station.set("SF") // classic form
station = "SF" // lazy assignment, same effect
constituents = listOf("M2", "S2", "K1") // ListProperty
csv.from("data/stations.csv") // ConfigurableFileCollection
csv = files("data/stations.csv") // assignment replaces the collection
report = layout.buildDirectory.file("report.txt") // RegularFileProperty
doLast { println(report.get().asFile.readText()) }
}
abstract class StationReport extends DefaultTask {
@Input
abstract Property<String> getStation()
@Input
abstract ListProperty<String> getConstituents()
@InputFiles
abstract ConfigurableFileCollection getCsv()
@OutputFile
abstract RegularFileProperty getReport()
@TaskAction
void write() {
report.get().asFile.text =
"station=${station.get()}\nconstituents=${constituents.get()}\ncsv=${csv.files*.name}\n"
}
}
tasks.register('stationReport', StationReport) {
station.set('SF') // classic form
station = 'SF' // assignment, same effect
constituents = ['M2', 'S2', 'K1'] // ListProperty
csv.from 'data/stations.csv' // ConfigurableFileCollection
csv = files('data/stations.csv') // assignment replaces the collection
report = layout.buildDirectory.file('report.txt') // RegularFileProperty
doLast { println report.get().asFile.text }
}
./gradlew stationReport -q --configuration-cache --warning-mode all
station=SF
constituents=[M2, S2, K1]
csv=[stations.csv]
Both DSLs printed the same text, and neither printed a deprecation warning. Both are compatible with the configuration cache: the task reads only its own
properties. The assignment is type-checked: station = 42 is a compile error whose message names the assign operator that the Kotlin DSL
plugs in, not the set method:
> Configure project :
e: file://<work>/labs/assign-fail/build.gradle.kts:7:5: None of the following candidates is applicable:
fun <T : Any> Property<T>.assign(value: T?): Unit:
Argument type mismatch: actual type is 'Int', but 'String?' was expected.
fun <T : Any> Property<T>.assign(value: Provider<out T>): Unit:
Argument type mismatch: actual type is 'Int', but 'Provider<out uninferred T (of fun <T : Any> Property<T>.assign)>' was expected.
e: file://<work>/labs/assign-fail/build.gradle.kts:7:13: No applicable 'assign' function found for '=' overload
Two details from the manual: a property with a custom setter does not get the assignment treatment, so the guidance for plugin authors is to declare lazy
properties as abstract getters; and the assignment is supported by IntelliJ IDEA from 2022.3 and Android Studio from Giraffe. The Provider and
NamedDomainObjectProvider types follow the same pattern: provider.map { ... } to chain, get() to read, namedObjectProvider.configure { ... } to
configure lazily. Gradle 8.14 behaves the same for = (default since 8.2; not run here). More on the lazy types is on
Lazy Configuration & Providers.
Source: Gradle: Lazy property assignment
Extra properties and the script object
Extra properties are a map available on every ExtensionAware object (projects, tasks, settings). The Kotlin DSL exposes it as extra; the Groovy DSL exposes
it as ext. The pair below creates a property, reads it, handles an undefined one, and puts a property on a task:
plugins {
java
}
extra["releaseName"] = "spring-tide" // create
val releaseName = extra["releaseName"] as String // read and cast
val maybe = if (extra.has("notDefined")) extra["notDefined"] else null // absent property
tasks.test {
extra["reportType"] = "dev" // extra property on a task
}
tasks.register("showExtra") {
// copy script-level values into locals: reading them in doLast would capture the script object
val release = releaseName
val missing = maybe
val type = tasks.test.map { it.extra["reportType"] as String }
doLast { println("release=$release maybe=$missing reportType=${type.get()}") }
}
plugins {
id 'java'
}
ext.releaseName = 'spring-tide' // create
def releaseName = ext.releaseName // read
def maybe = ext.has('notDefined') ? ext.notDefined : null // absent property
tasks.named('test') {
ext.reportType = 'dev' // extra property on a task
}
def reportType = tasks.named('test').map { it.ext.reportType }
tasks.register('showExtra') {
doLast { println "release=$releaseName maybe=$maybe reportType=${reportType.get()}" }
}
./gradlew showExtra -q --configuration-cache
release=spring-tide maybe=null reportType=dev
Both DSLs printed that line, and the second run reused the configuration cache entry in each. Three classic mistakes came up while building these examples, with the real messages:
-
as String?does not make an absent property null.extra["notDefined"] as String?failed withCannot get property 'notDefined' on extra properties extension as it does not exist; testextra.has("notDefined")first. -
In a task block,
extrais the task’s. Insidetasks.register("report") { ... }, a read ofextra["threshold"]failed withCould not create task ':report'.andCannot get property 'threshold' on extra properties extension as it does not exist, because the receiver of the lambda is the task. Writeproject.extra["threshold"]. -
A top-level
valread indoLastcaptures the script object. Gradle reported the problem below for the smallest possible case, which is why the example above copiesreleaseNameinto a localvalbeforedoLast:
val releaseName = "spring-tide"
tasks.register("showRelease") {
doLast { println("release=$releaseName") }
}
> Task :showRelease
release=spring-tide
...
* What went wrong:
Configuration cache problems found in this build.
1 problem was found storing the configuration cache.
- Task `:showRelease` of type `org.gradle.api.DefaultTask`: cannot serialize Gradle script object references as these are not supported with the configuration cache.
See https://docs.gradle.org/9.8.1/userguide/configuration_cache_requirements.html#config_cache:requirements:disallowed_types
...
Configuration cache entry discarded with 1 problem.
The task ran and printed its line, but the build failed on the configuration cache problem and the cache entry was discarded. Copying the value into a local val
inside the register block, as in the example above, makes the problem disappear. Groovy closures capture local def variables differently and the Groovy DSL examples on this page were
cache-compatible without that step. The configuration cache is covered on
Configuration Cache & Isolated Projects. Reading extra
properties of a parent project from a subproject (rootProject.extra["x"]) is accepted, while the implicit parent lookup (val x: String by project in a child
script) is deprecated since 9.6 and removed in Gradle 10; the supported ways are providers.gradleProperty(...) and a convention plugin, as described on
Settings & Build Scripts.
The kotlin-dsl plugin and precompiled script plugins
Build logic in Kotlin, such as buildSrc, an included build like build-logic, or a binary plugin, is compiled by the kotlin-dsl plugin. It applies the Kotlin plugin,
adds kotlin-stdlib, kotlin-reflect and gradleKotlinDsl() to the compileOnly and testImplementation configurations, passes the
compiler the same arguments as for scripts, registers the SAM-with-receiver compiler plugin, and enables precompiled script plugins: a file named
<id>.gradle.kts under src/main/kotlin becomes a plugin with that id. Each Gradle release is meant to be used with a specific version of the plugin; an unexpected version
prints a warning and may cause hard-to-diagnose problems (inside an included build the plugin version is not written at all).
The build below is a build-logic included build with one convention, used by an app module. The kotlin-dsl plugin needs a repository for the Kotlin
dependencies:
pluginManagement {
includeBuild("build-logic")
}
rootProject.name = "tidepool"
include("app")
plugins {
`kotlin-dsl`
}
repositories {
mavenCentral()
}
plugins {
`java-library`
}
java {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
}
}
tasks.withType<JavaCompile>().configureEach {
options.encoding = "UTF-8"
}
tasks.test {
useJUnitPlatform()
}
plugins {
id("tidepool.java-conventions")
}
Inside the precompiled script, the plugins {} block is what makes java {} and tasks.test work: accessors are generated for precompiled script plugins too,
which is the reason to put shared configuration there and not in a subprojects {} block. The build-logic tasks in the build output show the extra work that
the plugin does:
./gradlew :app:build --configuration-cache
Calculating task graph as no cached configuration is available for tasks: :app:build
> Task :build-logic:checkKotlinGradlePluginConfigurationErrors SKIPPED
> Task :build-logic:generateExternalPluginSpecBuilders
> Task :build-logic:extractPrecompiledScriptPluginPlugins
> Task :build-logic:generateScriptPluginAdapters
> Task :build-logic:pluginDescriptors
> Task :build-logic:processResources
> Task :build-logic:compilePluginsBlocks
> Task :build-logic:generatePrecompiledScriptPluginAccessors
> Task :build-logic:compileKotlin
> Task :build-logic:compileJava NO-SOURCE
> Task :build-logic:classes
> Task :build-logic:jar
> Task :app:compileJava
> Task :app:classes
> Task :app:jar
> Task :app:assemble
> Task :app:build
BUILD SUCCESSFUL in 6s
11 actionable tasks: 11 executed
Configuration cache entry stored.
(The NO-SOURCE and UP-TO-DATE lines of the app module were left out.) The example is compatible with the configuration cache. The Groovy equivalent
uses the groovy-gradle-plugin plugin in build-logic/build.gradle and a file named tidepool.java-conventions.gradle under src/main/groovy; it
ended with BUILD SUCCESSFUL for :app:build (9 actionable tasks, against 11 for the Kotlin variant, because it generates no Kotlin accessors); mixing the
two DSLs in one build is shown on
Groovy DSL & Migrating to Kotlin.
// build-logic/build.gradle
plugins {
id 'groovy-gradle-plugin'
}
// build-logic/src/main/groovy/tidepool.java-conventions.gradle
plugins {
id 'java-library'
}
java {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
}
}
tasks.withType(JavaCompile).configureEach {
options.encoding = 'UTF-8'
}
tasks.named('test') {
useJUnitPlatform()
}
Which Java does the build logic target? The manual says the plugin’s code targets Java 8 by default. On 9.8.1, with a JDK 26 daemon and no toolchain configured for
build-logic, the compiled script class had class-file major version 70, which is Java 26: javap -v on Tidepool_java_conventions_gradle.class printed
major version: 70. The property that used to set the target, kotlinDslPluginOptions.jvmTarget, was removed in 9.0.0; to fix the target, configure a Java toolchain
for the build-logic project. Since a precompiled plugin runs in the Gradle daemon, which needs JVM 17 or later from 9.0 on, a very low target buys nothing.
The compiler arguments that the plugin passes (and that scripts use) are -java-parameters, -jvm-default=enable (Kotlin interface members become default methods
for Java and Groovy implementors), -Xsam-conversions=class (anonymous classes instead of invokedynamic, for better configuration cache and incremental build
support), and -Xjsr305=strict with -Xjspecify-annotations=strict (strict nullability). Gradle 9.0 switched its API from JSR-305 to JSpecify annotations, and with Kotlin 2.1 or later some formerly valid code stops compiling: an unbounded generic such as
fun <T> Provider<T>.some() must become <T : Any>, Property<String?> is no longer allowed, and a Map<String, *> parameter no longer takes nullable values. More on convention plugins and build-logic is on
Convention Plugins & Sharing Build Logic and
Binary Plugins.
Source: Gradle: Kotlin DSL Plugin
The embedded Kotlin version and language level
Gradle ships kotlin-compiler-embeddable with matching kotlin-stdlib and kotlin-reflect, and the kotlin package from them is visible on the Gradle classpath.
The version belongs to the Gradle release, and Gradle only makes backward-incompatible Kotlin upgrades in major releases. The compatibility matrix lists the
relevant rows:
| Embedded Kotlin | Minimum Gradle | Kotlin language version |
|---|---|---|
1.3.10 |
5.0 |
1.3 |
1.8.10 |
8.0 |
1.8 |
2.0.21 |
8.12 |
1.8 |
2.2.0 |
9.0.0 |
2.2 |
2.3.0 |
9.4.0 |
2.2 |
2.4.0 |
9.7.0 |
2.2 |
2.4.10 |
9.8.0 |
2.2 |
The same page says Gradle is tested with Kotlin 2.0.0 through 2.4.20-RC2 for the Kotlin Gradle plugin used to compile your project. The Kotlin DSL language
level 2.2 applies to scripts and to build logic written in Kotlin, classic and convention plugins alike. Two consequences from the 9.0 upgrade guide: scripts can
no longer refer to themselves with labels like this@Build_gradle, this@Settings_gradle or this@Init_gradle (use project, settings or gradle), and
support for Kotlin language versions 1.4 to 1.7 in build logic was removed. Plugins built with the Kotlin DSL on Gradle 9.x can only be used on Gradle 8.11 or newer, the first release that embeds Kotlin 2 (to support older Gradle versions,
build the plugin with Gradle 8.x), and Kotlin Gradle plugin 2.0.0 is the lowest supported. For forward compatibility, the manual says the main concern is the external Kotlin Gradle plugin
against the kotlin-stdlib that Gradle ships, which stays fine while the versions are compatible. Gradle 8.14 embeds Kotlin 2.0.21 with language level 1.8
(not run here).
Interoperability with Groovy
A build can mix languages: Kotlin DSL scripts, Groovy DSL scripts, and tasks and plugins in Java, Groovy and Kotlin. Calling Java from Kotlin and Kotlin from Java is covered by the Kotlin reference; the Groovy boundary has a few Gradle-specific tools.
Groovy closures from Kotlin
Plugins written in Groovy may declare methods that take a Closure and not an Action<T>. Kotlin lambdas convert to Action<T> automatically; for a
Closure the DSL offers two helpers, closureOf<T> { ... } and delegateClosureOf<T> { ... }. Which one is right depends on how the Groovy method calls the
closure, which cannot be seen from the Kotlin side. This is a small plugin written in Groovy in a build-logic project, a plugin with a method that calls the closure
with an argument and one that sets a delegate:
package io.tidepool
import org.gradle.api.Plugin
import org.gradle.api.Project
class LegacyPlugin implements Plugin<Project> {
void apply(Project project) {
project.extensions.create('legacy', LegacyExtension)
}
}
class PackageSpec {
String name
String version
}
// A plugin written in Groovy: its methods take a Closure, not an Action
class LegacyExtension {
final PackageSpec spec = new PackageSpec()
// calls the closure with the spec as its argument
void pkg(Closure c) { c.call(spec) }
// sets the spec as the closure's delegate
void pkgDelegate(Closure c) {
c.delegate = spec
c.resolveStrategy = Closure.DELEGATE_FIRST
c.call()
}
String describe() { "${spec.name}:${spec.version}" }
}
The build-logic build applied kotlin-dsl and groovy and added implementation(localGroovy()); gradlePlugin { plugins { register("legacy") { ... } } }
registered the id tidepool.legacy. The consuming script:
import io.tidepool.PackageSpec
plugins {
id("tidepool.java-conventions")
id("tidepool.legacy")
}
legacy {
pkg(closureOf<PackageSpec> {
name = "tidepool-app"
version = "1.0.0"
})
}
tasks.register("describeLegacy") {
val text = legacy.describe()
doLast { println("legacy extension: $text") }
}
./gradlew :app:describeLegacy -q --configuration-cache
legacy extension: tidepool-app:1.0.0
All four combinations were run:
| Groovy method | closureOf<T> { ... } |
delegateClosureOf<T> { ... } |
|---|---|---|
|
works |
|
|
|
works |
The manual’s rule of thumb is that a NullPointerException with closureOf means delegateClosureOf is the right helper. On 9.8.1 the same mismatch showed as the
non-null parameter error in the table. Gradle plugins should declare Action<T>, which both languages convert without helpers. For closures used for other
purposes, KotlinClosure0, KotlinClosure1 and KotlinClosure2 adapt Kotlin functions with up to two parameters.
withGroovyBuilder and Groovy scripts
When a plugin relies on Groovy metaprogramming, withGroovyBuilder { ... } attaches the Groovy semantics to any object: method calls written as strings
("name"("parameters", 42)), blocks ("blockName" { ... }), named arguments ("another"("name" to "example")) and getProperty, setProperty and invokeMethod. The Ant
builder, a dynamic Groovy object, is the standard example. The second part of the script applies a Groovy DSL script that holds the dynamic part:
tasks.register("antEcho") {
val outDir = layout.buildDirectory.dir("ant-out").get().asFile
doLast {
// the Ant builder is a Groovy object: names become method calls
ant.withGroovyBuilder {
"echo"("message" to "hello from AntBuilder")
"mkdir"("dir" to outDir)
}
}
}
// a Groovy script can hold the dynamic parts
apply(from = "legacy.gradle")
def message = 'configured by legacy.gradle'
tasks.register('groovyHello') {
doLast { println "Hello from Groovy: ${message}" }
}
./gradlew antEcho groovyHello --configuration-cache
Calculating task graph as no cached configuration is available for tasks: antEcho groovyHello
> Task :groovyHello
Hello from Groovy: configured by legacy.gradle
> Task :antEcho
[ant:echo] hello from AntBuilder
BUILD SUCCESSFUL in 415ms
2 actionable tasks: 2 executed
Configuration cache entry stored.
A first draft of this example failed the configuration cache check, which is worth knowing: it read layout.buildDirectory inside doLast and project.ext inside the
Groovy script’s doLast, and Gradle reported Invocation of 'Task.project' by task ':groovyHello' at execution time is unsupported with the configuration cache.
The version above computes the directory at configuration time and uses a Groovy local def, and is compatible. Other rules from the manual: to call a Kotlin function
with named arguments from Groovy, pass positional values (and all of them, when the Kotlin function has defaults); to call a Groovy function with named arguments from
Kotlin, pass a Map<String, ?> (mapOf("parameterName" to "value")); extension functions are called as static functions with the receiver as first argument
(TheTargetTypeKt.kotlinExtensionFunction(receiver, ...) in Groovy). See
Scripting in Ant for the other Ant-and-Groovy bridge, and
Groovy DSL & Migrating to Kotlin for the migration path.
Compilation warnings
Kotlin DSL scripts are compiled during configuration, and the compiler’s deprecation warnings appear in the console as w: lines. The property
org.gradle.kotlin.dsl.allWarningsAsErrors=true in gradle.properties turns them into failures. A script that uses a deprecated Kotlin function:
val title = "tidal".capitalize()
tasks.register("showTitle") {
doLast { println(title) }
}
./gradlew showTitle
> Configure project :
w: file://<work>/labs/warn/build.gradle.kts:1:21: 'fun String.capitalize(): String' is deprecated. Use replaceFirstChar instead.
> Task :showTitle
Tidal
BUILD SUCCESSFUL in 371ms
With org.gradle.kotlin.dsl.allWarningsAsErrors=true added to gradle.properties, the same run stopped at configuration:
> Configure project :
w: file://<work>/labs/warn/build.gradle.kts:1:21: 'fun String.capitalize(): String' is deprecated. Use replaceFirstChar instead.
FAILURE: Build failed with an exception.
* Where:
Build file '<work>/labs/warn/build.gradle.kts' line: 1
* What went wrong:
Script compilation error:
Line 1: val title = "tidal".capitalize()
^ 'fun String.capitalize(): String' is deprecated. Use replaceFirstChar instead.
1 error
(Blank lines of the real output were removed.) The warning appears only when the script is compiled: the compiled result is cached, so a second run of an unchanged script does not repeat it, which is why the edit of the string was needed before the second capture. The Groovy DSL has no equivalent switch: Groovy script problems surface when the line runs.
Source: Gradle: Compilation warnings
Limitations, performance and IDE support
-
The Kotlin DSL is slower than the Groovy DSL on first use (clean checkouts, ephemeral CI agents), because compiling Kotlin scripts is slower. Changing something in
buildSrcinvalidates build-script caching too. Script compilation avoidance skips recompiling a script when only the non-ABI part of its classpath changed, and Gradle 9.0 made the ABI-change detection much better. The manual notes known issues, and the system propertyorg.gradle.kotlin.dsl.scriptCompilationAvoidanceset tofalsedisables it. These timings were not measured here. -
In IntelliJ IDEA, the project must be imported from the Gradle model for content assist and refactoring in Kotlin DSL scripts. The manual recommends disabling the automatic build import and enabling the automatic reloading of script dependencies, which gives early feedback while leaving the full sync under your control.
-
The
model {}block of the discontinued Gradle Software Model is not supported. -
Builds with slow configuration time make the IDE less responsive; configuration cache and avoiding configuration-time work (see Build Lifecycle) help both.
IDE support, from the manual:
| IDE | Build import | Syntax highlighting | Semantic editor (completion, navigation, refactoring) |
|---|---|---|---|
IntelliJ IDEA |
yes |
yes |
yes |
Android Studio |
yes |
yes |
yes |
Eclipse IDE, CLion, Apache NetBeans, Visual Studio Code (LSP) |
yes |
yes |
no |
Visual Studio |
yes |
no |
no |
If something looks wrong in the editor, run ./gradlew tasks from the command line first: if the command line works, restart the IDE and invalidate its caches. The Kotlin DSL logs are in
$HOME/Library/Logs/gradle-kotlin-dsl on macOS, $HOME/.gradle-kotlin-dsl/log on Linux and $HOME/AppData/Local/gradle-kotlin-dsl/log on Windows, and the
org.gradle.kotlin.dsl.logging.tapi system property adds detail to the daemon log. Where the Kotlin plugin of the IDE is the cause, report the problem to the IDE vendor’s tracker.
Sources: Gradle: Limitations, Gradle: IDE support and Gradle: Troubleshooting
Where the book is outdated
Gradle in Action (Gradle 1.7) is written around the Groovy DSL only, and uses the constructs below. The current state, from the release notes and the user manual:
| The book | Gradle 9.8.1 |
|---|---|
Groovy DSL only |
Kotlin DSL 1.0 in 5.0; the default for |
|
removed in 5.0; |
|
the |
|
removed in 7.0; |
the |
removed in 7.0; |
Source: Gradle 8.2 release notes
Classic problems
| Message (Gradle 9.8.1) | Cause | Fix |
|---|---|---|
|
the plugin was applied with |
use the |
|
a configuration created after the |
keep a variable: |
|
a |
|
|
the cast |
test |
|
inside a task block, |
|
|
|
copy the value into a local |
|
the assigned value does not match the |
convert the value, or assign a |
|
the |
|
Summary
-
Kotlin DSL scripts are
*.gradle.ktsfiles compiled during configuration againstProject,SettingsorGradle; one build may mix both DSLs. -
Type-safe accessors exist for what the
plugins {}block applied; otherwise use strings andconfigure<T>,the<T>()andtasks.named<T>. -
Prefer the lazy container operations (
register,named,withType<T>().configureEach); theby registering,by existing,by gettingandby extradelegates are deprecated since 9.6. -
=assigns lazy properties (default since 8.2); copy script-level values into locals beforedoLastfor the configuration cache. -
Use the
kotlin-dslplugin forbuild-logic; Gradle 9.8.1 embeds Kotlin 2.4.10 and compiles with language version 2.2. -
Cross into Groovy with
closureOf/delegateClosureOf,withGroovyBuilderorapply(from = "x.gradle").