W3docs

Java Gradle Build Script

Anatomy of a Gradle build script for Java — plugins, dependencies, tasks, and DSL basics.

A Gradle build script describes how to compile, test, and package a project — not as a rigid XML document, but as code. Gradle reads a build.gradle file (Groovy DSL) or build.gradle.kts file (Kotlin DSL), turns the declarations inside into a graph of tasks, and runs only the tasks your command needs, in the right order. Where Maven gives you a fixed lifecycle, Gradle gives you a programmable one: applying a plugin, declaring a dependency, and defining a task are all ordinary statements in a real language.

This page assumes you already know what Gradle is at a high level — if not, start with the Gradle introduction. Here we take apart a real build.gradle block by block: plugins, repositories, dependencies, and tasks.

The anatomy of build.gradle

A minimal Java build script has four blocks: which plugins to apply, where to fetch dependencies, what those dependencies are, and a little configuration. Here is a complete, idiomatic one in the Kotlin DSL:

plugins {
    java
    application
}

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

repositories {
    mavenCentral()
}

dependencies {
    implementation("com.google.guava:guava:32.1.3-jre")
    testImplementation("org.junit.jupiter:junit-jupiter:5.10.0")
}

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

tasks.test {
    useJUnitPlatform()
}

The java plugin alone gives you compileJava, test, jar, and a dozen more tasks for free. The application plugin adds run and installDist. The tasks.test { useJUnitPlatform() } line wires the test task to run JUnit 5 tests — without it Gradle defaults to the legacy JUnit 4 engine and silently runs nothing. Everything after that is configuration of what those tasks do.

Plugins, repositories, and the build lifecycle

Almost nothing in Gradle is built in — capabilities arrive as plugins. The java plugin is the foundation for JVM work; others layer on top:

PluginWhat it adds
javacompileJava, test, jar, source sets, the dependencies configurations
applicationrun and a packaged distribution with start scripts
java-libraryThe api vs implementation distinction for libraries
org.springframework.bootbootJar, bootRun for Spring Boot apps
jacocoCode-coverage reporting wired into test

repositories { } tells Gradle where to download dependencies from — mavenCentral() is the usual choice. Without a repository, no external dependency can be resolved.

Declaring dependencies and their scopes

Dependencies are declared with a configuration that controls where they appear on the classpath. Picking the right one keeps your compile classpath clean and your builds fast:

dependencies {
    // On the compile and runtime classpath, but NOT exposed to consumers
    implementation("org.apache.commons:commons-lang3:3.14.0")

    // Part of this library's public API — leaks to consumers (java-library only)
    api("com.google.guava:guava:32.1.3-jre")

    // Needed to compile, but provided at runtime by the environment
    compileOnly("org.projectlombok:lombok:1.18.30")

    // Only on the test classpath
    testImplementation("org.junit.jupiter:junit-jupiter:5.10.0")

    // Only at runtime (e.g. a JDBC driver loaded by name)
    runtimeOnly("org.postgresql:postgresql:42.7.1")
}

The coordinate format is group:name:version — the same coordinates Maven uses in its pom.xml. When two dependencies pull in the same module at different versions, Gradle's default strategy puts a single, highest version on the classpath — the runnable example below models exactly this.

Tasks: the unit of work

Every action Gradle performs is a task, and tasks declare dependencies on other tasks. Running gradle build does not run one thing; it walks a graph and executes each prerequisite once. You can also define your own:

tasks.register("printVersion") {
    group = "help"
    description = "Prints the project version."
    doLast {
        println("Project version is $version")
    }
}

// Make the jar task wait for our custom task
tasks.named("jar") {
    dependsOn("printVersion")
}

Two more facts make Gradle fast. First, it is incremental: a task whose inputs and outputs are unchanged is reported UP-TO-DATE and skipped. Second, the Gradle Wrapper (./gradlew, backed by gradle/wrapper/gradle-wrapper.properties) pins one Gradle version per project, so every developer and CI machine builds with the same toolchain — you never install Gradle globally.

A worked example: a build, modelled in plain Java

Gradle itself is not on this runner, so the program below models the three ideas that make a build script work — the task graph and its execution order, incremental up-to-date skipping, and dependency version conflict resolution — using nothing but the JDK. It is the mental model gradle build executes for real.

java— editable, runs on the server

What to take from the run:

  • The task list for gradle build is computed by a topological sort, not written out by hand. compileJava and processResources come before classes, which comes before jar and test, which come before build — exactly the order Gradle prints with each :taskName prefix, because a task can only run after everything it dependsOn has finished.
  • A diamond in the graph runs a shared prerequisite once, not twice. Both jar and test depend on classes, yet classes appears a single time in the order — the done set is what stops Gradle from recompiling the same code for every downstream task.
  • The second run shows Gradle's incremental behaviour: compileJava, processResources, and classes are UP-TO-DATE and skipped, so only 3 tasks actually execute. This is why an unchanged project rebuilds in milliseconds — Gradle compares task inputs and outputs and does no work it can avoid.
  • Dependency resolution collapses a version conflict to one winner: slf4j-api is requested at both 2.0.9 (direct) and 1.7.36 (transitive via guava), but the resolved classpath lists it once at 2.0.9. Gradle's default strategy is highest-version-wins, so a single, consistent jar lands on the classpath instead of two clashing copies.
  • The final line names the Gradle version 8.7 as if read from gradle-wrapper.properties. In a real project the wrapper stores that version in source control, so ./gradlew build uses the same Gradle for everyone — the build is reproducible regardless of what (if anything) is installed on the machine.

Practice

Practice
A Gradle Java project declares 'org.slf4j:slf4j-api:2.0.9' directly, while a transitive dependency pulls in 'org.slf4j:slf4j-api:1.7.36'. With Gradle's default resolution strategy, what ends up on the classpath?
A Gradle Java project declares 'org.slf4j:slf4j-api:2.0.9' directly, while a transitive dependency pulls in 'org.slf4j:slf4j-api:1.7.36'. With Gradle's default resolution strategy, what ends up on the classpath?
Was this page helpful?