Java Gradle Build Script
Anatomy of a Gradle build script for Java — plugins, dependencies, tasks, and DSL basics.
Java Gradle Build Script
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.
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. 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:
| Plugin | What it adds |
|---|---|
java | compileJava, test, jar, source sets, the dependencies configurations |
application | run and a packaged distribution with start scripts |
java-library | The api vs implementation distinction for libraries |
org.springframework.boot | bootJar, bootRun for Spring Boot apps |
jacoco | Code-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. 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.
What to take from the run:
- The task list for
gradle buildis computed by a topological sort, not written out by hand.compileJavaandprocessResourcescome beforeclasses, which comes beforejarandtest, which come beforebuild— exactly the order Gradle prints with each:taskNameprefix, because a task can only run after everything itdependsOnhas finished. - A diamond in the graph runs a shared prerequisite once, not twice. Both
jarandtestdepend onclasses, yetclassesappears a single time in the order — thedoneset is what stops Gradle from recompiling the same code for every downstream task. - The second run shows Gradle's incremental behaviour:
compileJava,processResources, andclassesareUP-TO-DATEand skipped, so only3tasks 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-apiis requested at both2.0.9(direct) and1.7.36(transitive via guava), but the resolved classpath lists it once at2.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.7as if read fromgradle-wrapper.properties. In a real project the wrapper stores that version in source control, so./gradlew builduses the same Gradle for everyone — the build is reproducible regardless of what (if anything) is installed on the machine.
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?