Gradle with the Kotlin DSL

Write a build.gradle.kts for a Java 25 service with toolchains, dependencies and tests, and avoid the configuration-time mistakes that slow builds.

Syntaxplugins { application } java { toolchain { languageVersion = JavaLanguageVersion.of(25) } } dependencies { implementation("group:artifact:version") } application { mainClass = "com.example.App" }

Gradle is the other mainstream Java build tool. A build is described in Kotlin in build.gradle.kts, next to a settings.gradle.kts that names the project and lists its sub-projects. The Kotlin DSL is the default for new Gradle builds: it is statically typed, so the IDE can complete it and show errors before the build runs, which works far less well with the older Groovy DSL (build.gradle).

Plugins and tasks

Plugins add tasks and conventions. java adds compiling, testing and JAR tasks and uses the same src/main/java layout as Maven. application adds run and start scripts. java-library adds the api configuration for libraries. Common tasks are build (assemble and check), test, run and clean. Output goes to build/ rather than target/, and the JAR to build/libs/.

Unlike Maven phases, Gradle tasks form a graph and run only what the requested task needs. Gradle also skips tasks whose inputs and outputs have not changed, so a second ./gradlew build with no edits finishes almost at once.

Toolchains

The java.toolchain block says which JDK compiles, tests and runs the code. Gradle finds a matching JDK on the machine, or downloads one if a toolchain resolver plugin is configured. This separates the JDK that runs Gradle from the JDK that builds your code, so a laptop with several JDKs and a CI agent produce the same bytecode.

Configuration and execution

Gradle first runs every build script to configure the task graph, then executes the tasks that were requested. Code at the top level of a build script runs on every invocation, even ./gradlew help, and so does the configuration block of a task created eagerly with tasks.create. Register custom tasks with tasks.register, whose configuration block runs only if the task is needed, and put the actual work inside doLast { } so it runs only when the task executes.

Testing with JUnit

tasks.test { useJUnitPlatform() } is required for JUnit 5 and 6. Since Gradle 9, the JUnit Platform launcher must also be declared with testRuntimeOnly; without it the test task fails before any test runs.

Version catalogs

Multi-project builds often keep versions in gradle/libs.versions.toml and refer to them as libs.postgresql in build scripts. The catalog is just a list of coordinates in one place; it does not resolve conflicts, which is what platforms and BOMs are for.

Always run Gradle through the wrapper, ./gradlew, so the Gradle version comes from the repository and not from whatever is installed.

Example

Example · kotlin
// build.gradle.kts
plugins {
    application
}

group = "com.example"
version = "1.0.0-SNAPSHOT"

java {
    // Compile and test with JDK 25, whatever JDK runs Gradle itself
    toolchain {
        languageVersion = JavaLanguageVersion.of(25)
    }
}

repositories {
    mavenCentral()
}

dependencies {
    implementation("com.zaxxer:HikariCP:7.0.2")
    runtimeOnly("org.postgresql:postgresql:42.7.13")

    testImplementation(platform("org.junit:junit-bom:6.0.3"))
    testImplementation("org.junit.jupiter:junit-jupiter")
    testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}

application {
    mainClass = "com.example.orders.App"
}

tasks.test {
    useJUnitPlatform()
}

When to use it

  • Build a Java 25 orders service on CI agents that run Gradle on an older JDK, using a toolchain.
  • Keep a dozen services on the same library versions with one shared version catalog.
  • Speed up local builds by moving file reading out of the configuration phase into task actions.

More examples

settings.gradle.kts and a version catalog

rootProject.name sets the JAR name; without it Gradle uses the folder name, which differs between a developer checkout and a CI workspace. Dashes in catalog names become dots in the accessors, so junit-bom is libs.junit.bom.

Example · kotlin
// settings.gradle.kts
rootProject.name = "orders-service"

// gradle/libs.versions.toml (TOML, read by Gradle automatically):
// [versions]
// junit = "6.0.3"
// [libraries]
// hikaricp = "com.zaxxer:HikariCP:7.0.2"
// postgresql = "org.postgresql:postgresql:42.7.13"
// junit-bom = { module = "org.junit:junit-bom", version.ref = "junit" }

// build.gradle.kts then uses type-safe accessors:
dependencies {
    implementation(libs.hikaricp)
    runtimeOnly(libs.postgresql)
    testImplementation(platform(libs.junit.bom))
}

A custom task done lazily

The version is read while configuring and captured as a plain String, so the doLast action does not touch the project object while running, which the configuration cache requires. Running ./gradlew -q printVersion prints only the version.

Example · kotlin
// Registered lazily: configured only if something asks for this task
tasks.register("printVersion") {
    group = "help"
    description = "Prints the project version for CI scripts."
    val projectVersion = project.version.toString()
    doLast {
        // Runs only when the task executes
        println(projectVersion)
    }
}

tasks.test {
    useJUnitPlatform()
    testLogging {
        events("failed", "skipped")
    }
}

Everyday Gradle commands

--tests filters by class or method name, and --args passes program arguments to run. A build scan uploads details of the build to a hosted service, so only use it where your organisation allows that.

Example · bash
./gradlew build                      # compile, test, assemble build/libs/*.jar
./gradlew test --tests 'com.example.orders.OrderServiceTest'
./gradlew run --args='--port=8080'   # from the application plugin
./gradlew tasks                      # what can I run here?
./gradlew build --scan               # publish a build scan for debugging
./gradlew clean build --no-build-cache

Discussion

  • Be the first to comment on this lesson.