Skip to main content
Java · lesson 42 of 66
Open the Java page →
Java intermediate Lesson 42 of 66

Also part of: JUnit

Gradle — Modern Java Build Tool

Learn Gradle, the fast and flexible Java build tool: Kotlin DSL build scripts, the wrapper, dependencies, tests, custom tasks and multi-project builds.

By SkillByExample · Updated

Gradle builds Java projects from a script written in Kotlin (or Groovy) instead of XML. It compiles, tests and packages your code, downloads dependencies, and skips any task whose inputs haven’t changed. Every command below was run for real with Gradle 8.14.3 on JDK 21 in an empty directory; timings in the BUILD SUCCESSFUL in … lines vary from run to run.

A minimal project

Gradle uses the same layout as Maven: src/main/java and src/test/java. Two build files sit at the root. settings.gradle.kts names the project:

// File: settings.gradle.kts
rootProject.name = "my-app"

build.gradle.kts applies plugins and declares dependencies. The application plugin adds a run task; it also brings in the java plugin.

// File: build.gradle.kts
plugins {
    application
}

repositories {
    mavenCentral()
}

dependencies {
    implementation("com.google.guava:guava:33.4.8-jre")
}

application {
    mainClass = "com.example.App"
}
// File: src/main/java/com/example/App.java
package com.example;

import com.google.common.base.Joiner;
import java.util.Arrays;

public class App {
    static String greet(String... names) {
        return "Hello, " + Joiner.on(" and ").skipNulls().join(Arrays.asList(names)) + "!";
    }

    public static void main(String[] args) {
        System.out.println(greet("Alice", null, "Bob"));
    }
}

The wrapper, then the first run

Generate the wrapper once with an installed gradle; from then on everyone uses ./gradlew, which downloads the pinned Gradle version on first use.

$ gradle wrapper --gradle-version 8.14.3 -q
$ ls
build.gradle.kts
gradle
gradlew
gradlew.bat
settings.gradle.kts
src
$ ./gradlew run --console=plain
> Task :compileJava
> Task :processResources NO-SOURCE
> Task :classes

> Task :run
Hello, Alice and Bob!

BUILD SUCCESSFUL in 1s
2 actionable tasks: 2 executed

run depended on compileJava, so Gradle compiled first, resolving Guava from Maven Central (this machine already had the Gradle distribution and Guava cached, so nothing was downloaded; on a fresh machine the first ./gradlew also prints a download progress bar). skipNulls() dropped the null name. Commit gradlew, gradlew.bat and the gradle/ directory so CI and teammates get exactly this Gradle version.

Incremental builds: UP-TO-DATE

Run the same task again without changing anything:

$ ./gradlew run --console=plain
> Task :compileJava UP-TO-DATE
> Task :processResources NO-SOURCE
> Task :classes UP-TO-DATE

> Task :run
Hello, Alice and Bob!

BUILD SUCCESSFUL in 847ms
2 actionable tasks: 1 executed, 1 up-to-date

compileJava was skipped because its inputs (sources, classpath, compiler settings) were unchanged. run always executes because it has no declared outputs to compare.

Tests with JUnit 5

Add test dependencies and tell the test task to use the JUnit Platform. The junit-bom platform pins matching versions for the JUnit artifacts.

// File: build.gradle.kts
plugins {
    application
}

repositories {
    mavenCentral()
}

dependencies {
    implementation("com.google.guava:guava:33.4.8-jre")
    testImplementation(platform("org.junit:junit-bom:5.12.2"))
    testImplementation("org.junit.jupiter:junit-jupiter")
    testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}

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

tasks.test {
    useJUnitPlatform()
    testLogging { events("passed", "failed") }
}
// File: src/test/java/com/example/AppTest.java
package com.example;

import static org.junit.jupiter.api.Assertions.assertEquals;
import org.junit.jupiter.api.Test;

class AppTest {
    @Test void skipsNulls() { assertEquals("Hello, Alice and Bob!", App.greet("Alice", null, "Bob")); }
    @Test void singleName() { assertEquals("Hello Alice!", App.greet("Alice")); }
}
$ ./gradlew test --console=plain
> Task :compileJava UP-TO-DATE
> Task :processResources NO-SOURCE
> Task :classes UP-TO-DATE
> Task :compileTestJava
> Task :processTestResources NO-SOURCE
> Task :testClasses

> Task :test FAILED

AppTest > singleName() FAILED
    org.opentest4j.AssertionFailedError at AppTest.java:9

AppTest > skipsNulls() PASSED

2 tests completed, 1 failed

FAILURE: Build failed with an exception.

* What went wrong:
Execution failed for task ':test'.
> There were failing tests. See the report at: file:///tmp/demo/build/reports/tests/test/index.html

* Try:
> Run with --scan to get full insights.

BUILD FAILED in 1s
3 actionable tasks: 2 executed, 1 up-to-date

The second test expected a missing comma. A failing test fails the whole build, and Gradle writes an HTML report under build/reports/tests/. Fix the expectation and run again:

// File: src/test/java/com/example/AppTest.java
package com.example;

import static org.junit.jupiter.api.Assertions.assertEquals;
import org.junit.jupiter.api.Test;

class AppTest {
    @Test void skipsNulls() { assertEquals("Hello, Alice and Bob!", App.greet("Alice", null, "Bob")); }
    @Test void singleName() { assertEquals("Hello, Alice!", App.greet("Alice")); }
}
$ ./gradlew test --console=plain
> Task :compileJava UP-TO-DATE
> Task :processResources NO-SOURCE
> Task :classes UP-TO-DATE
> Task :compileTestJava
> Task :processTestResources NO-SOURCE
> Task :testClasses

> Task :test

AppTest > singleName() PASSED

AppTest > skipsNulls() PASSED

BUILD SUCCESSFUL in 1s
3 actionable tasks: 2 executed, 1 up-to-date

Only the changed test class was recompiled; compileJava stayed UP-TO-DATE. The per-test PASSED/FAILED lines come from the testLogging block. ./gradlew build runs test too, so a red test also stops packaging.

Dependency configurations

Where a dependency goes decides which classpath it lands on.

ConfigurationCompileRuntimeUse for
implementationyesyesnormal dependencies (default choice)
api (needs java-library)yes, and exportedyestypes that appear in your library’s public API
compileOnlyyesnoannotation-only libraries, provided APIs
runtimeOnlynoyesJDBC drivers, logging backends
testImplementationteststestsJUnit, Mockito

dependencies shows what was actually resolved, including transitive dependencies:

$ ./gradlew dependencies --configuration runtimeClasspath -q

------------------------------------------------------------
Root project 'my-app'
------------------------------------------------------------

runtimeClasspath - Runtime classpath of source set 'main'.
\--- com.google.guava:guava:33.4.8-jre
     +--- com.google.guava:failureaccess:1.0.3
     +--- com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava
     +--- org.jspecify:jspecify:1.0.0
     +--- com.google.errorprone:error_prone_annotations:2.36.0
     \--- com.google.j2objc:j2objc-annotations:3.0.0

A web-based, searchable dependency report is available by adding the --scan option.

One line in build.gradle.kts brought in six artifacts. When two versions of a library meet, this tree shows which one won (marked ->).

Custom tasks

Tasks are code. register creates one lazily, doLast holds the action, and dependsOn orders tasks.

// File: build.gradle.kts
plugins {
    application
}

repositories {
    mavenCentral()
}

dependencies {
    implementation("com.google.guava:guava:33.4.8-jre")
}

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

tasks.register("hello") {
    group = "custom"
    description = "Prints a greeting"
    doLast { println("Hello from Gradle ${gradle.gradleVersion}") }
}

tasks.register<Zip>("zipSources") {
    dependsOn("hello")
    from("src/main/java")
    archiveFileName = "sources.zip"
    destinationDirectory = layout.buildDirectory.dir("dist")
}
$ ./gradlew zipSources --console=plain

> Task :hello
Hello from Gradle 8.14.3

> Task :zipSources

BUILD SUCCESSFUL in 947ms
2 actionable tasks: 2 executed
$ ./gradlew zipSources --console=plain

> Task :hello
Hello from Gradle 8.14.3

> Task :zipSources UP-TO-DATE

BUILD SUCCESSFUL in 861ms
2 actionable tasks: 1 executed, 1 up-to-date
$ ./gradlew tasks --group custom -q

------------------------------------------------------------
Tasks runnable from root project 'my-app'
------------------------------------------------------------

Custom tasks
------------
hello - Prints a greeting

To see all tasks and more detail, run gradlew tasks --all

To see more detail about a task, run gradlew help --task <task>

zipSources is a typed Zip task, so Gradle knows its inputs and output and can skip it the second time. hello has no declared outputs, so it runs every time. --group custom lists only that group; plain ./gradlew tasks hides ungrouped tasks such as zipSources unless you add --all.

Multi-project builds

A settings file can include several subprojects; one can depend on another with project(":name").

// File: multi/settings.gradle.kts
rootProject.name = "shop"
include("core", "web")
// File: multi/core/build.gradle.kts
plugins { `java-library` }
// File: multi/core/src/main/java/shop/core/Prices.java
package shop.core;

public class Prices {
    public static String format(long cents) { return String.format("$%d.%02d", cents / 100, cents % 100); }
}
// File: multi/web/build.gradle.kts
plugins { application }

dependencies { implementation(project(":core")) }

application { mainClass = "shop.web.Main" }
// File: multi/web/src/main/java/shop/web/Main.java
package shop.web;

import shop.core.Prices;

public class Main {
    public static void main(String[] args) { System.out.println("Total: " + Prices.format(2599)); }
}
$ ./gradlew -p multi :web:run --console=plain
> Task :core:compileJava
> Task :core:processResources NO-SOURCE
> Task :core:classes
> Task :core:jar
> Task :web:compileJava
> Task :web:processResources NO-SOURCE
> Task :web:classes

> Task :web:run
Total: $25.99

BUILD SUCCESSFUL in 1s
4 actionable tasks: 4 executed

Asking for :web:run made Gradle build core first and put its jar on web’s classpath. Editing only web later recompiles only web. -p multi points the wrapper at the build in the multi directory.

Common commands

CommandDoes
./gradlew buildcompile, test, and assemble jars
./gradlew testrun tests
./gradlew runrun the application (application plugin)
./gradlew cleandelete build/
./gradlew build -x testbuild but skip the test task
./gradlew taskslist tasks
./gradlew dependenciesshow resolved dependency trees

Practice

  1. Add a runtimeOnly dependency on org.slf4j:slf4j-simple:2.0.17 and confirm with dependencies that it is on the runtime classpath but not the compile classpath.
Solution
// File: build.gradle.kts
plugins {
    application
}

repositories {
    mavenCentral()
}

dependencies {
    implementation("com.google.guava:guava:33.4.8-jre")
    runtimeOnly("org.slf4j:slf4j-simple:2.0.17")
}

application {
    mainClass = "com.example.App"
}
$ ./gradlew dependencies --configuration compileClasspath -q | grep slf4j
$ ./gradlew dependencies --configuration runtimeClasspath -q | grep slf4j
\--- org.slf4j:slf4j-simple:2.0.17
     \--- org.slf4j:slf4j-api:2.0.17

The first grep finds nothing: code can’t import anything from a runtimeOnly dependency, but the jar is there when the program runs — the right scope for a logging backend or JDBC driver.

  1. Pass command-line arguments through ./gradlew run and print them from main.
Solution
// File: src/main/java/com/example/App.java
package com.example;

import com.google.common.base.Joiner;
import java.util.Arrays;

public class App {
    static String greet(String... names) {
        return "Hello, " + Joiner.on(" and ").skipNulls().join(Arrays.asList(names)) + "!";
    }

    public static void main(String[] args) {
        System.out.println(args.length == 0 ? greet("World") : greet(args));
    }
}
$ ./gradlew run -q --args="Ada Grace"
Hello, Ada and Grace!

--args is split on spaces and passed to main; quote a single argument containing spaces inside it (--args="'Ada Lovelace'").

  1. Restore the JUnit build file from the tests section, run ./gradlew build twice, and explain which tasks run the second time.
Solution
// File: build.gradle.kts
plugins {
    application
}

repositories {
    mavenCentral()
}

dependencies {
    implementation("com.google.guava:guava:33.4.8-jre")
    testImplementation(platform("org.junit:junit-bom:5.12.2"))
    testImplementation("org.junit.jupiter:junit-jupiter")
    testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}

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

tasks.test {
    useJUnitPlatform()
}
$ ./gradlew build --console=plain -q
$ ./gradlew build --console=plain
> Task :compileJava UP-TO-DATE
> Task :processResources NO-SOURCE
> Task :classes UP-TO-DATE
> Task :jar UP-TO-DATE
> Task :startScripts UP-TO-DATE
> Task :distTar UP-TO-DATE
> Task :distZip UP-TO-DATE
> Task :assemble UP-TO-DATE
> Task :compileTestJava UP-TO-DATE
> Task :processTestResources NO-SOURCE
> Task :testClasses UP-TO-DATE
> Task :test UP-TO-DATE
> Task :check UP-TO-DATE
> Task :build UP-TO-DATE

BUILD SUCCESSFUL in 867ms
7 actionable tasks: 7 up-to-date

Nothing changed, so every task — including test — is skipped. build also produced build/distributions/*.zip and .tar because the application plugin adds distribution tasks.

  1. Add a shout task that depends on hello and prints in upper case, then run only shout.
Solution
// File: build.gradle.kts
plugins {
    application
}

repositories {
    mavenCentral()
}

dependencies {
    implementation("com.google.guava:guava:33.4.8-jre")
}

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

tasks.register("hello") {
    doLast { println("Hello from Gradle") }
}

tasks.register("shout") {
    dependsOn("hello")
    doLast { println("HELLO FROM GRADLE!") }
}
$ ./gradlew shout -q
Hello from Gradle
HELLO FROM GRADLE!

dependsOn makes hello run first even though only shout was requested.

Frequently Asked Questions

Should I use Gradle or Maven?
Both are fine for backend Java. Maven is more common in enterprise and has a larger ecosystem of plugins. Gradle is the standard for Android, is faster due to incremental builds and build caching, and its DSL is more expressive. Spring Initializr offers both, so either is a valid choice for a new Spring Boot project.
What is the Kotlin DSL vs Groovy DSL?
Gradle supports two script languages. The Groovy DSL (build.gradle) is older and more concise. The Kotlin DSL (build.gradle.kts) provides IDE autocompletion, type safety, and refactoring support, and has been the default for `gradle init` since Gradle 8.2.
What does UP-TO-DATE mean in Gradle output?
Gradle records each task's inputs and outputs. If nothing changed since the last run, it skips the task and labels it UP-TO-DATE. This incremental behaviour is why a second build of an unchanged project finishes in a fraction of the time of the first.
Why should I commit the Gradle wrapper to version control?
The wrapper scripts (gradlew, gradlew.bat) and gradle/wrapper/gradle-wrapper.properties pin the exact Gradle version for the project. Anyone who clones the repository, including a CI server, runs ./gradlew and gets that version downloaded automatically, so every build uses the same tool.