Gradle Java Projects
|
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 (the current release on 2026-10-16). Every example was run through the Gradle wrapper on macOS with JDK 26 (Oracle java version "26", which is both the JVM that runs the daemon and the JDK that compiles the code, because no toolchain is configured; toolchains are the subject of Gradle Toolchains). The Kotlin DSL is shown first and the Groovy DSL second wherever the syntax differs, and both versions of the tidepool build were run with the same commands; the console output is the same apart from timings and from the order in which independent tasks are reported. Every example ran with the configuration cache on (org.gradle.configuration-cache=true in gradle.properties) and reported no configuration cache problems, so all of them are compatible with it (one diagnostic run used --no-configuration-cache --warning-mode all to list deprecation warnings, because a reused cache entry does not repeat them). The scratch project pointed its wrapper at a local copy of the distribution archive; the properties file that a real project commits is
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionSha256Sum=dce76f55f8e251a3a1f130eb120f30b3d271de2b76c9b0729d316b5a1b6dc01f
distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.1-bin.zip
networkTimeout=10000
retries=0
retryBackOffMs=500
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
In the listings the lines about the configuration cache (Calculating task graph ..., Configuration cache entry stored. and the like) and the line that points to the experimental problems report are left out, and the project directory of the scratch copy is replaced by /work/tidepool. A line consisting of ... marks output that was cut. The JVM default locale on the machine was Spanish, so the one program output that has a decimal fraction uses a comma (-0,662 m); the same program prints -0.662 m in an English locale. Features newer than Gradle 8.14 name their release. Gradle 8.14 was not installed here, so what differs in it comes from the official documentation and is labelled "not run here".
A Java build looks the same in every JVM build tool: put sources and resources in agreed places, compile them against a classpath that comes from the dependency declarations, run the tests, and pack the result. What differs is who knows the conventions. Ant knows none of them, so a build file lists every directory and every classpath (see Compiling Java with Ant). Maven knows one fixed set, which it calls the standard directory layout (see Maven Philosophy, Coordinates & Directory Layout). Gradle ships the conventions as plugins, every one of them can be changed in the build script, and the plugin you apply decides how much of the Java build you get: compilation only, a library with a public API, or a runnable application. This page is about those plugins, about the objects underneath them (source sets, configurations and tasks), and about the parts of a Java build that are specific to Gradle: compile avoidance, incremental compilation, annotation processors, and the split between api and implementation. The shared vocabulary (build, project, task) is on Core Concepts; the dependency notation used here is explained on Declaring Dependencies & Configurations; running tests, test suites and coverage are on Testing JVM Projects; publishing the JARs built here is on Publishing. For the language itself see Java Build & Tooling; for the Kotlin side see Kotlin Build & Tooling.
The JVM plugins at a glance
The plugins build on each other. Applying one applies the plugins below it, so a build script almost always names exactly one of them (the one at the top of the stack it needs).
| Plugin id | What it adds | Use it for |
|---|---|---|
|
The |
Plugin authors who want the Java model without the |
|
The source sets |
A module that is compiled, tested and packed, but that is not consumed as a library by other projects. |
|
Everything of |
Any project that other projects depend on, such as tidepool-model and tidepool-engine. |
|
Everything of |
A program with a |
|
Everything of |
Web applications for a servlet container (see below). |
|
Everything of |
Projects written wholly or partly in those languages (see below). |
|
A platform of constraints without code, the |
Each has its own page: Platforms, Constraints & Rich Versions, Testing JVM Projects and Gradle Toolchains. |
Source: Gradle User Manual: Building Java & JVM projects, The Java Plugin, The Java Library Plugin, The Application Plugin
The role of java-library in a build with several projects is the reason it is the default choice for libraries: the api configuration says which dependencies are part of the library’s public face, and everything else stays out of the compile classpath of the consumers (see "Libraries: api and implementation" below).
The example build: three projects, in both DSLs
The running example of the section is tidepool, a small tide prediction toolkit: a model library (Station, Constituent, Height, TideEvent records), an engine library on top of it (the TideEngine and a StationReader that uses Commons CSV), and a command line application built with picocli. As a Gradle build it is one root project with three subprojects, which is the structure described on Multi Project Builds. This page uses the build to show what the Java plugins do; the files are shown once, here, and the sections below refer to them.
The settings file declares the repository once for all projects, forbids repositories in project scripts and includes the three projects. The version catalog and gradle.properties are the same for both DSLs.
dependencyResolutionManagement {
repositoriesMode = RepositoriesMode.FAIL_ON_PROJECT_REPOS
repositories {
mavenCentral()
}
}
rootProject.name = "tidepool"
include("tidepool-model", "tidepool-engine", "tidepool-cli")
dependencyResolutionManagement {
repositoriesMode = RepositoriesMode.FAIL_ON_PROJECT_REPOS
repositories {
mavenCentral()
}
}
rootProject.name = 'tidepool'
include 'tidepool-model', 'tidepool-engine', 'tidepool-cli'
org.gradle.configuration-cache=true
group=io.tidepool
version=1.0.0
[versions]
commons-csv = "1.14.1"
picocli = "4.7.7"
junit = "6.1.3"
[libraries]
commons-csv = { module = "org.apache.commons:commons-csv", version.ref = "commons-csv" }
picocli = { module = "info.picocli:picocli", version.ref = "picocli" }
picocli-codegen = { module = "info.picocli:picocli-codegen", version.ref = "picocli" }
The root project applies only the base plugin (its script is plugins { base } in the Kotlin DSL and plugins { id 'base' } in the Groovy DSL). The three project scripts follow. Each is explained in the section named in the paragraph after it.
plugins {
`java-library`
}
java {
withSourcesJar()
withJavadocJar()
}
tasks.withType<JavaCompile>().configureEach {
options.release = 21
options.compilerArgs.add("-Xlint:all")
}
tasks.javadoc {
(options as StandardJavadocDocletOptions).addBooleanOption("Xdoclint:all,-missing", true)
}
tasks.jar {
manifest {
attributes(
"Implementation-Title" to project.name,
"Implementation-Version" to project.version,
)
}
}
testing {
suites {
named<JvmTestSuite>("test") {
useJUnitJupiter(libs.versions.junit)
}
}
}
plugins {
id 'java-library'
}
java {
withSourcesJar()
withJavadocJar()
}
tasks.withType(JavaCompile).configureEach {
options.release = 21
options.compilerArgs.add('-Xlint:all')
}
tasks.javadoc {
options.addBooleanOption('Xdoclint:all,-missing', true)
}
tasks.jar {
manifest {
attributes(
'Implementation-Title': project.name,
'Implementation-Version': project.version,
)
}
}
testing {
suites {
test {
useJUnitJupiter(libs.versions.junit)
}
}
}
The model is a plain library: it compiles for Java 21 (options.release, see "Compiling Java"), turns on every lint check, attaches a sources JAR and a Javadoc JAR to the build, puts two entries into the JAR manifest, and uses JUnit Jupiter through the test suite DSL ("Packaging" and "Compiling Java" cover them).
plugins {
`java-library`
}
dependencies {
api(project(":tidepool-model"))
implementation(libs.commons.csv)
}
tasks.withType<JavaCompile>().configureEach {
options.release = 21
options.compilerArgs.add("-Xlint:all")
}
testing {
suites {
named<JvmTestSuite>("test") {
useJUnitJupiter(libs.versions.junit)
}
}
}
// A hand-made source set for slower tests that read the real data file.
val integrationTest = sourceSets.register("integrationTest") {
compileClasspath += sourceSets.main.get().output
runtimeClasspath += sourceSets.main.get().output
}
configurations[integrationTest.get().implementationConfigurationName]
.extendsFrom(configurations.testImplementation.get())
configurations[integrationTest.get().runtimeOnlyConfigurationName]
.extendsFrom(configurations.testRuntimeOnly.get())
val integrationTestTask = tasks.register<Test>("integrationTest") {
description = "Runs the integration tests."
group = "verification"
testClassesDirs = integrationTest.get().output.classesDirs
classpath = integrationTest.get().runtimeClasspath
useJUnitPlatform()
shouldRunAfter(tasks.test)
}
tasks.check {
dependsOn(integrationTestTask)
}
plugins {
id 'java-library'
}
dependencies {
api project(':tidepool-model')
implementation libs.commons.csv
}
tasks.withType(JavaCompile).configureEach {
options.release = 21
options.compilerArgs.add('-Xlint:all')
}
testing {
suites {
test {
useJUnitJupiter(libs.versions.junit)
}
}
}
// A hand-made source set for slower tests that read the real data file.
sourceSets {
integrationTest {
compileClasspath += sourceSets.main.output
runtimeClasspath += sourceSets.main.output
}
}
configurations {
integrationTestImplementation.extendsFrom testImplementation
integrationTestRuntimeOnly.extendsFrom testRuntimeOnly
}
def integrationTestTask = tasks.register('integrationTest', Test) {
description = 'Runs the integration tests.'
group = 'verification'
testClassesDirs = sourceSets.integrationTest.output.classesDirs
classpath = sourceSets.integrationTest.runtimeClasspath
useJUnitPlatform()
shouldRunAfter tasks.test
}
tasks.check {
dependsOn integrationTestTask
}
The engine exposes the model in its public API (api) and keeps Commons CSV to itself (implementation), and it owns a hand-made source set for integration tests ("Libraries: api and implementation" and "A custom source set: integration tests").
plugins {
application
}
dependencies {
implementation(project(":tidepool-engine"))
implementation(libs.picocli)
annotationProcessor(libs.picocli.codegen)
}
application {
mainClass = "io.tidepool.cli.Main"
applicationName = "tidepool"
applicationDefaultJvmArgs = listOf("-Xmx256m")
}
distributions {
main {
contents {
from("../data") {
into("data")
}
}
}
}
tasks.withType<JavaCompile>().configureEach {
options.release = 21
options.compilerArgs.addAll(listOf("-Xlint:all", "-Aproject=${project.group}/${project.name}"))
}
// One executable JAR with the runtime classpath unpacked into it.
tasks.register<Jar>("fatJar") {
description = "Assembles a single executable JAR."
group = "build"
archiveClassifier = "all"
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
manifest {
attributes("Main-Class" to application.mainClass)
}
from(sourceSets.main.map { it.output })
dependsOn(configurations.runtimeClasspath)
from(configurations.runtimeClasspath.map { classpath ->
classpath.map { if (it.isDirectory) it else zipTree(it) }
})
}
plugins {
id 'application'
}
dependencies {
implementation project(':tidepool-engine')
implementation libs.picocli
annotationProcessor libs.picocli.codegen
}
application {
mainClass = 'io.tidepool.cli.Main'
applicationName = 'tidepool'
applicationDefaultJvmArgs = ['-Xmx256m']
}
distributions {
main {
contents {
from('../data') {
into 'data'
}
}
}
}
tasks.withType(JavaCompile).configureEach {
options.release = 21
options.compilerArgs.addAll(['-Xlint:all', "-Aproject=${project.group}/${project.name}"])
}
// One executable JAR with the runtime classpath unpacked into it.
tasks.register('fatJar', Jar) {
description = 'Assembles a single executable JAR.'
group = 'build'
archiveClassifier = 'all'
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
manifest {
attributes('Main-Class': application.mainClass)
}
from(sourceSets.main.output)
dependsOn(configurations.runtimeClasspath)
from {
configurations.runtimeClasspath.collect { it.directory ? it : zipTree(it) }
}
}
The command line project is an application with an annotation processor, a distribution that also ships the data file, and a task that builds one executable JAR ("Annotation processors", "Packaging" and "Applications").
These files are this page’s own setup for the examples, not an example taken from the manual.
Source sets and the standard layout
A source set is Gradle’s name for a group of sources that are compiled together and run with one classpath: it bundles a source directory, a resources directory, an output directory, and a set of dependency configurations. The Java plugin creates two, main (the production code, which ends up in the JAR) and test (compiled against main and used to run tests). The layout it expects is the one every Java developer knows, and nothing has to exist: the plugin compiles whatever it finds and ignores what is missing.
tidepool-cli/build.gradle.kts
tidepool-cli/src/main/java/io/tidepool/cli/Main.java
tidepool-engine/build.gradle.kts
tidepool-engine/src/integrationTest/java/io/tidepool/engine/StationReaderIT.java
tidepool-engine/src/main/java/io/tidepool/engine/StationReader.java
tidepool-engine/src/main/java/io/tidepool/engine/TideEngine.java
tidepool-engine/src/test/java/io/tidepool/engine/TideEngineTest.java
tidepool-model/build.gradle.kts
tidepool-model/src/main/java/io/tidepool/model/Constituent.java
tidepool-model/src/main/java/io/tidepool/model/Height.java
tidepool-model/src/main/java/io/tidepool/model/Station.java
tidepool-model/src/main/java/io/tidepool/model/TideEvent.java
tidepool-model/src/test/java/io/tidepool/model/HeightTest.java
The listing is the source tree of the three projects (generated files in build/ left out; the three build.gradle.kts files are build.gradle in the Groovy DSL). The directory of a source set is always src/<name>/java for Java and src/<name>/resources for resources, so the hand-made source set integrationTest of the engine lives in src/integrationTest/java, with no further configuration. The build output is as regular as the input:
Directory under build/ |
Contents |
|---|---|
|
The compiled classes of a source set ( |
|
The processed resources of a source set. |
|
Java sources that annotation processors generated while compiling the source set. |
|
JNI headers, if the compiler is asked for them. |
|
JARs: |
|
The archives of the |
|
The output of the |
|
Test result XML files, reports, and scratch files of tasks. |
The output of the model project after a build shows the regular names (a few reports and tmp directories left out):
build
build/classes
build/classes/java
build/classes/java/main
build/classes/java/main/io
build/classes/java/main/io/tidepool
build/classes/java/main/io/tidepool/model
build/classes/java/test
build/classes/java/test/io
build/classes/java/test/io/tidepool
build/classes/java/test/io/tidepool/model
build/docs
build/docs/javadoc
build/generated
build/generated/sources
build/generated/sources/annotationProcessor
build/generated/sources/annotationProcessor/java
build/generated/sources/annotationProcessor/java/main
build/generated/sources/annotationProcessor/java/test
build/generated/sources/headers
build/generated/sources/headers/java
build/generated/sources/headers/java/main
build/generated/sources/headers/java/test
build/libs
Every source set has the same family of names, derived from the source set name. That is how the plugin gives a source set called integrationTest its own compileIntegrationTestJava task and integrationTestImplementation configuration without any code:
For source set name |
What exists |
|---|---|
Tasks |
|
Declarable configurations |
|
Resolvable configurations |
|
Source set properties |
|
Source: Gradle User Manual: The Java Plugin, Source sets, Source set properties, Dependency management
The Maven equivalent of "source set" does not exist: the project has exactly one main and one test tree, and anything else (integration tests, generated sources) needs plugins that add source roots. The Ant equivalent is a <javac> task per tree (see the comparison table at the end of the page).
Changing the layout
Setting a property replaces its value; calling the matching method adds to it. That distinction, setSrcDirs against srcDir in the Kotlin DSL (srcDirs = against srcDir in the Groovy DSL), is the way to read most of the source set API. A legacy project whose sources live in src/java instead of src/main/java is described to Gradle like this:
sourceSets {
main {
java {
setSrcDirs(listOf("src/java"))
}
}
}
sourceSets {
main {
java {
srcDirs = ['src/java']
}
}
}
Both versions were run after moving the model’s sources to tidepool-model/src/java/io/tidepool/model (tests stay in src/test/java):
> Task :tidepool-model:compileJava
BUILD SUCCESSFUL in 660ms
1 actionable task: 1 executed
tidepool-model/src/java/io/tidepool/model/Constituent.java
tidepool-model/src/java/io/tidepool/model/Height.java
tidepool-model/src/java/io/tidepool/model/Station.java
tidepool-model/src/java/io/tidepool/model/TideEvent.java
tidepool-model/src/test/java/io/tidepool/model/HeightTest.java
The build compiled the classes into the usual build/classes/java/main regardless of where the sources were. Adding a directory next to the default one uses srcDir("thirdParty/src/main/java"). A source set exists to give a set of sources its own classpath and its own output, so the guidance of the user manual is: if the sources need a unique classpath, produce classes that are handled differently from main and test, and are a natural part of the project, define a source set; generated sources that belong in the production JAR are better added to main (with the compile task depending on the generator).
Source: Gradle User Manual: Customizing file and directory locations, Compiling independent sources separately
Resources need no configuration at all: files in src/main/resources are copied by processResources into build/resources/main and packed into the JAR, and the task has no sources (NO-SOURCE in the listings below) when the directory is absent. Replacing tokens in resource files uses the expand and filter methods of the Copy family described on Working with Files; the Maven equivalent, resource filtering, is on Maven Resources & Filtering.
The tasks the Java plugin adds
The plugin registers the tasks of the table and wires them with dependsOn relations into a graph that you rarely change. The graph, with the aggregate (lifecycle) tasks classes, testClasses, assemble, check and build at the joints:
| Task | Type and depends on | What it does |
|---|---|---|
|
|
Compiles |
|
|
Copies |
|
lifecycle; |
Everything needed to run the main classes. Other plugins attach further compile tasks to it. |
|
|
Packs the classes and resources of |
|
|
Runs the |
|
|
The same three steps for |
|
|
Runs the unit tests (see Testing JVM Projects). |
|
|
Deletes |
|
lifecycle (from the |
|
|
task rule |
Builds the artifacts attached to a configuration ( |
The task graph for a build of one project is cheap to print with --dry-run. The engine depends on the model, so the model’s compile steps come first; integrationTest is the task the hand-made source set contributes and check ends up depending on it:
./gradlew :tidepool-engine:build --dry-run
:tidepool-model:compileJava SKIPPED
:tidepool-engine:compileJava SKIPPED
:tidepool-engine:processResources SKIPPED
:tidepool-engine:classes SKIPPED
:tidepool-engine:jar SKIPPED
:tidepool-engine:assemble SKIPPED
:tidepool-engine:compileTestJava SKIPPED
:tidepool-engine:processTestResources SKIPPED
:tidepool-engine:testClasses SKIPPED
:tidepool-model:processResources SKIPPED
:tidepool-model:classes SKIPPED
:tidepool-model:jar SKIPPED
:tidepool-engine:test SKIPPED
:tidepool-engine:compileIntegrationTestJava SKIPPED
:tidepool-engine:processIntegrationTestResources SKIPPED
:tidepool-engine:integrationTestClasses SKIPPED
:tidepool-engine:integrationTest SKIPPED
:tidepool-engine:check SKIPPED
:tidepool-engine:build SKIPPED
BUILD SUCCESSFUL in 1s
Two lifecycle conventions are worth knowing. check is where every verification task belongs: the test task is attached by the plugin, a second Test task or a Checkstyle task is attached by you (see the integration-test section below), and gradle check then means "everything that can fail the build". assemble is where every artifact belongs: anything that should be produced by a normal build, like the sources JAR or a distribution, is attached to it by the plugin that creates it. The tasks buildNeeded and buildDependents, which build a project together with the projects it depends on, or that depend on it, are deprecated since Gradle 9.6 (see Multi Project Builds).
The Maven equivalent of this section is the lifecycle with its bound goals (compile, test, package, verify), see Maven Build Lifecycle; in Ant the same graph is written by hand as targets, see Ant Targets & Extension Points.
Compiling Java
compileJava is a JavaCompile task, and nearly everything you can tell the compiler is a property of that task type: options.release, options.compilerArgs, options.encoding, options.isFork, options.isFailOnError (options.fork and options.failOnError in the Groovy DSL), options.isIncremental, and the annotation processor path. Configure all of them at once, for compileJava and compileTestJava and every other source set, with tasks.withType<JavaCompile>().configureEach { ... } (tasks.withType(JavaCompile).configureEach { ... } in the Groovy DSL), as the three scripts above do. tasks.compileJava { ... } configures only the main compile task.
Which Java version does the code target?
Three settings answer the question, with very different guarantees, and mixing them up is the classic Java-on-Gradle bug.
| Setting | What it does | Guarantee |
|---|---|---|
Toolchain ( |
Compiles, tests, documents and runs with a JDK of that version, found on the machine or provisioned. |
The strongest: language level, class file version and the JDK API all match. See Gradle Toolchains. |
|
Passes |
Almost as strong, and cheap: one newer JDK can build for older targets. Gradle supports it when the compiling JDK is Java 10 or later. |
|
Pass |
Weak, and the user manual says it is "not generally advised". |
When nothing is configured, the compiler targets the language level of the JVM that runs Gradle (the javac behaviour with no flag). The model compiled with its options.release line removed produced Java 26 class files on this machine’s JDK 26 (major version: 70):
major version: 70
Source: Gradle User Manual: Targeting a specific Java version, Using Java release version, Using Java compatibility options
The difference between release and the compatibility properties shows with one line of code. List.reversed() exists since Java 21. A class in the model that calls it:
package io.tidepool.model;
import java.util.List;
/** Returns the most recent event of a chronologically ordered list. */
public final class Latest {
private Latest() {
}
public static TideEvent of(List<TideEvent> events) {
return events.reversed().getFirst();
}
}
compiled with the compatibility properties set to 17:
java {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
java {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
> Task :tidepool-model:processResources NO-SOURCE
> Task :tidepool-model:compileJava
> Task :tidepool-model:classes
> Task :tidepool-model:jar
BUILD SUCCESSFUL in 629ms
2 actionable tasks: 2 executed
javap -v -cp tidepool-model/build/classes/java/main io.tidepool.model.Latest | grep "major version"
major version: 61
The build succeeded and produced Java 17 class files that call a Java 21 method. Running them on a Java 17 JVM (JetBrains Runtime 17.0.14, with a small Check.java launched with java -cp tidepool-model-1.0.0.jar Check.java) fails at the call, while the same JAR runs on JDK 26:
Exception in thread "main" java.lang.NoSuchMethodError: 'java.util.List java.util.List.reversed()'
at io.tidepool.model.Latest.of(Latest.java:11)
at Check.main(Check.java:7)
TideEvent[time=1970-01-01T00:00:00Z, height=Height[metres=1.0], high=true]
The first listing is the Java 17 run and the second the JDK 26 run. With options.release = 17 (both DSLs were run) the same source does not compile, which is the point of the flag:
tasks.withType<JavaCompile>().configureEach {
options.release = 17
}
tasks.withType(JavaCompile).configureEach {
options.release = 17
}
> Task :tidepool-model:compileJava FAILED
/work/tidepool/tidepool-model/src/main/java/io/tidepool/model/Latest.java:11: error: cannot find symbol
return events.reversed().getFirst();
^
symbol: method reversed()
location: variable events of type List<TideEvent>
1 error
FAILURE: Build failed with an exception.
* What went wrong:
Execution failed for task ':tidepool-model:compileJava' (registered by plugin class 'org.gradle.api.plugins.JavaBasePlugin').
> Compilation failed; see the compiler output below.
/work/tidepool/tidepool-model/src/main/java/io/tidepool/model/Latest.java:11: error: cannot find symbol
return events.reversed().getFirst();
^
symbol: method reversed()
location: variable events of type List<TideEvent>
1 error
* Try:
> Check your code and dependencies to fix the compilation error(s)
> Run with --scan to get full insights from a Build Scan (powered by Develocity).
BUILD FAILED in 559ms
1 actionable task: 1 executed
Gradle reports compiler diagnostics where they happen (in the task output) and again under "What went wrong"; the lines cannot find symbol and symbol: method reversed() are the whole story. With --continue the errors of every failing compile task are collected in one report.
The release flag also protects the consumer side. The Java plugins publish the target version of every project as the attribute org.gradle.jvm.version, and a project that compiles for an older Java than a dependency refuses it. With the CLI project set to options.release = 17 while the engine stays on 21, the compile classpath cannot be resolved (both DSLs gave the same message):
FAILURE: Build failed with an exception.
* What went wrong:
Could not determine the dependencies of task ':tidepool-cli:compileJava'.
> Could not resolve all dependencies for configuration ':tidepool-cli:compileClasspath'.
> Could not resolve project ':tidepool-engine'.
Required by:
project ':tidepool-cli'
> Dependency resolution is looking for a library compatible with JVM runtime version 17, but 'project ':tidepool-engine'' is only compatible with JVM runtime version 21 or newer.
* Try:
> Change the dependency on 'project ':tidepool-engine'' to an earlier version that supports JVM runtime version 17.
> Run with --stacktrace option to get the stack trace.
> Run with --info or --debug option to get more log output.
> Run with --scan to get full insights from a Build Scan (powered by Develocity).
> Get more help at https://help.gradle.org.
BUILD FAILED in 803ms
The Maven counterparts are maven.compiler.release and the toolchains (see Maven Compiling & Toolchains); in Ant the attributes are release, or source and target on <javac> (see Compiling Java with Ant).
Compiler options and lint
The projects pass -Xlint:all through options.compilerArgs, so every warning category of javac is on. Other options that builds commonly add are -Werror (warnings fail the build), -parameters (keep parameter names in the class files, which some frameworks and annotation processors need), options.encoding = "UTF-8" (the default of the JDK 18 and later is already UTF-8, but older JDKs use the platform charset), and options.isFork = true with options.forkOptions to compile in a separate JVM. Javac warnings are printed in the task output. Builds that printed warnings also ended with an [Incubating] Problems report is available at ... line that points to an HTML report under build/reports/problems/; that line is left out of the listings.
The Javadoc of the model is checked by doclint. The records have no @param tags, so the javadoc task in the model script switches the missing group off with addBooleanOption("Xdoclint:all,-missing", true); without it, the task prints one warning per record component and still succeeds.
Compile avoidance and incremental compilation
Two separate features keep compilation cheap, and both look at the binary interface of classes rather than at the files.
Compile avoidance concerns consumers of a project. The compile classpath of a task is fingerprinted by the ABI of the classes on it (the part of a class that other classes can see: public and protected members, signatures, constants), and a change that does not alter the ABI does not make dependents out of date. Changing a method body, a comment, private members, a resource or the name of a JAR leaves downstream compileJava tasks UP-TO-DATE. With the java-library plugin the consumer even compiles against the classes directory of the project, so a change of a resource or the work of the jar task is not part of the compile at all.
Incremental compilation concerns one compile task. Gradle recompiles the classes affected by a change (the changed ones and the ones that depend on them, by type references in bytecode) and leaves the others in the output directory untouched.
The first change to the engine is private: a new case in the switch inside predict. Nothing is visible to the CLI, so tidepool-cli:compileJava stays up to date even though the engine was recompiled:
./gradlew :tidepool-cli:compileJava
> Task :tidepool-model:compileJava UP-TO-DATE
> Task :tidepool-engine:compileJava
> Task :tidepool-cli:compileJava UP-TO-DATE
BUILD SUCCESSFUL in 657ms
3 actionable tasks: 1 executed, 2 up-to-date
The second change adds a public method to TideEngine. That is a change of the ABI, so the CLI is recompiled as well:
> Task :tidepool-model:compileJava UP-TO-DATE
> Task :tidepool-engine:compileJava
> Task :tidepool-cli:compileJava
warning: [processing] No processor claimed any of these annotations: /picocli.CommandLine.Command,/picocli.CommandLine.Parameters
1 warning
BUILD SUCCESSFUL in 627ms
3 actionable tasks: 2 executed, 1 up-to-date
The runs happened in this order in one project (a first compile, then the private change, then the public one). The third experiment shows the incremental compiler with --info: adding a method to the Height record in the model recompiled two classes, not four:
./gradlew :tidepool-model:compileJava --info
> Task :tidepool-model:compileJava
Caching disabled for task ':tidepool-model:compileJava' because:
Build cache is disabled
Task ':tidepool-model:compileJava' is not up-to-date because:
Input property 'stableSources' file /work/tidepool/tidepool-model/src/main/java/io/tidepool/model/Height.java has changed.
Compilation mode: in-process compilation
Created classpath snapshot for incremental compilation in 0.0 secs.
Compiling with toolchain '/Library/Java/JavaVirtualMachines/jdk-26.jdk/Contents/Home'.
Compiling with JDK Java compiler API.
Incremental compilation of 2 classes completed in 0.02 secs.
Class dependency analysis for incremental compilation took 0.0 secs.
BUILD SUCCESSFUL in 512ms
The lines to look for are Incremental compilation of 2 classes completed (the class that changed and the one that depends on it) and, when compilation is not incremental, Full recompilation is required because ... followed by the reason. The known reasons, from the user manual, include a changed annotation of source retention, a changed Java constant, a changed resource that an annotation processor reads, a custom executable or javaHome on the task, and the build after a compile error. The analysis is kept in the project directory and is an output of the task in the build cache.
Source: Gradle User Manual: Compilation avoidance, Incremental Java compilation, Using classes instead of jar for compilation
Maven recompiles a whole module when the compiler plugin detects a change, and Ant’s <javac> compares the time stamp of each source file with its class file; neither knows about the ABI of dependencies (see Maven Compiling & Toolchains). Both ran the same experiments in the Groovy DSL build with identical task states.
Annotation processors
Annotation processors are the part of the compile classpath that Gradle treats as a separate world. A processor must be declared in the annotationProcessor configuration of the source set; Gradle ignores processors that are only on the compile classpath, because implementation details of a processor matter for the result of the compilation and must therefore be part of the up-to-date check. The library that holds the annotations is still declared in implementation or compileOnly, as the user manual’s Dagger example shows.
The CLI uses picocli. The picocli-codegen processor reads the @Command and @Parameters annotations at compile time and writes the reflection configuration that GraalVM native image needs. It takes the output location from an option (-Aproject), which the build passes as a compiler argument:
> Task :tidepool-cli:dependencies
------------------------------------------------------------
Project ':tidepool-cli'
------------------------------------------------------------
annotationProcessor - Annotation processors and their dependencies for source set 'main'.
\--- info.picocli:picocli-codegen:4.7.7
\--- info.picocli:picocli:4.7.7
The listing is the result of ./gradlew :tidepool-cli:dependencies --configuration annotationProcessor (cut after the dependency tree). The processor wrote three files into the class output:
tidepool-cli/build/classes/java/main/META-INF/native-image/picocli-generated/io.tidepool/tidepool-cli/proxy-config.json
tidepool-cli/build/classes/java/main/META-INF/native-image/picocli-generated/io.tidepool/tidepool-cli/reflect-config.json
tidepool-cli/build/classes/java/main/META-INF/native-image/picocli-generated/io.tidepool/tidepool-cli/resource-config.json
[
{
"name" : "io.tidepool.cli.Main",
"allDeclaredConstructors" : true,
"allPublicConstructors" : true,
"allDeclaredMethods" : true,
"allPublicMethods" : true,
"fields" : [
{ "name" : "file" },
{ "name" : "id" }
]
},
...
(The second listing is the head of reflect-config.json; the file continues with picocli’s own help mixin.) With -Xlint:all every compile of the CLI prints one more line:
warning: [processing] No processor claimed any of these annotations: /picocli.CommandLine.Command,/picocli.CommandLine.Parameters
A processor "claims" annotations by returning true from its process method. The picocli processor writes its files without claiming the annotations, so the lint check prints this line even though the processor ran; the generated files are the proof. If you do not want to see it, leave processing out of the lint categories (-Xlint:all,-processing).
Whether a processor is incremental decides how much of the compile avoidance and incremental compilation above survives. Changing Main.java in the CLI and compiling again with --info shows the reason in one line:
> Task :tidepool-cli:compileJava
Caching disabled for task ':tidepool-cli:compileJava' because:
Build cache is disabled
Task ':tidepool-cli:compileJava' is not up-to-date because:
Input property 'stableSources' file /work/tidepool/tidepool-cli/src/main/java/io/tidepool/cli/Main.java has changed.
Compilation mode: in-process compilation
Created classpath snapshot for incremental compilation in 0.0 secs.
Full recompilation is required because picocli.codegen.aot.graalvm.processor.NativeImageConfigGeneratorProcessor is not incremental. Analysis took 0.0 secs.
Compiling with toolchain '/Library/Java/JavaVirtualMachines/jdk-26.jdk/Contents/Home'.
Compiling with JDK Java compiler API.
warning: [processing] No processor claimed any of these annotations: /picocli.CommandLine.Command,/picocli.CommandLine.Parameters
1 warning
Class dependency analysis for incremental compilation took 0.0 secs.
BUILD SUCCESSFUL in 286ms
picocli-codegen is not incremental, so every change of a CLI source file is a full recompilation of the CLI project (which has one class). The incremental annotation processing support of Gradle applies to processors that opt in by listing themselves in META-INF/gradle/incremental.annotation.processors as isolating, aggregating or dynamic. Processors in the first category look at each annotated type on its own and generate files for it; the aggregating ones combine several types into one output, and Gradle always reprocesses all annotated files for them. All of them must create files through the Filer API, must read only annotations with class or runtime retention, and must not depend on compiler-internal types. The user manual keeps a table of the popular processors that support the feature.
Source: Gradle User Manual: Incremental annotation processing, Making an annotation processor incremental, Compilation avoidance, annotation processors
The same job in Maven is done with annotationProcessorPaths of the compiler plugin (see Maven Compiling & Toolchains), and in Ant with <compilerarg> and a processor path (see Compiling Java with Ant).
Libraries: api and implementation
Which dependencies a consumer sees is the one question that separates a library from a plain Java module. The java-library plugin adds the configuration api and the rule that decides what goes on whose classpath. The user manual’s test for placing a dependency is the binary interface of the library: a dependency belongs in api if at least one of its types is used in a superclass or interface, a public or protected method signature (parameters, return types, generic arguments), a public field, or a public annotation. A type that appears only in method bodies or private members belongs in implementation. Prefer implementation: if a type leaks, the consumers fail to compile at once, which is the signal to move the dependency.
| Configuration | Compile classpath of the project | Compile classpath of consumers |
|---|---|---|
|
Yes |
Yes, transitively |
|
Yes |
No (runtime classpath only) |
|
Yes |
No |
|
Yes |
Yes (for a dependency that is needed to compile against the library but not at runtime, such as an annotation JAR) |
|
No |
No (runtime classpath of consumers only) |
Source: Gradle User Manual: API and implementation separation, Recognizing API and implementation dependencies, The Java Library configurations
In the example build the structure is:
The engine’s StationReader returns Station objects, which are model types in a public method signature, so the model is api of the engine; Commons CSV is used only inside method bodies, so it is implementation. dependencies --configuration compileClasspath shows what the CLI can compile against. The CLI sees the engine and, through the api edge, the model, but not Commons CSV:
Project ':tidepool-cli'
------------------------------------------------------------
compileClasspath - Compile classpath for source set 'main'.
+--- project ':tidepool-engine'
| \--- project ':tidepool-model'
\--- info.picocli:picocli:4.7.7
At run time everything is needed, and runtimeClasspath of the CLI also contains Commons CSV and its two dependencies (this is the content of the distribution lib directory further down):
Project ':tidepool-cli'
------------------------------------------------------------
runtimeClasspath - Runtime classpath of source set 'main'.
+--- project ':tidepool-engine'
| +--- project ':tidepool-model'
| \--- org.apache.commons:commons-csv:1.14.1
| +--- commons-io:commons-io:2.20.0
| \--- commons-codec:commons-codec:1.19.0
\--- info.picocli:picocli:4.7.7
The most common mistake is declaring a leaked type as implementation. With implementation(project(":tidepool-model")) in the engine script (both DSLs were run), the CLI’s Main still imports io.tidepool.model.Station, and its compilation stops:
> Task :tidepool-model:compileJava
> Task :tidepool-engine:compileJava
> Task :tidepool-cli:compileJava FAILED
warning: [processing] No processor claimed any of these annotations: /picocli.CommandLine.Command,/picocli.CommandLine.Parameters
/work/tidepool/tidepool-cli/src/main/java/io/tidepool/cli/Main.java:5: error: package io.tidepool.model does not exist
import io.tidepool.model.Station;
^
1 error
1 warning
FAILURE: Build failed with an exception.
* What went wrong:
Execution failed for task ':tidepool-cli:compileJava' (registered by plugin class 'org.gradle.api.plugins.JavaBasePlugin').
> Compilation failed; see the compiler output below.
warning: [processing] No processor claimed any of these annotations: /picocli.CommandLine.Command,/picocli.CommandLine.Parameters
/work/tidepool/tidepool-cli/src/main/java/io/tidepool/cli/Main.java:5: error: package io.tidepool.model does not exist
import io.tidepool.model.Station;
^
1 error
1 warning
* Try:
> Check your code and dependencies to fix the compilation error(s)
> Run with --scan to get full insights from a Build Scan (powered by Develocity).
BUILD FAILED in 641ms
3 actionable tasks: 3 executed
...
The message says nothing about dependencies. It says that a package does not exist, which is what a missing compile-time dependency looks like to javac; the first check is the compileClasspath report above. The opposite mistake, making everything api, compiles but leaks: consumers start to use transitive types by accident, are recompiled when an implementation dependency changes, and the generated POM or Gradle Module Metadata puts the dependency into the compile scope.
The variants that a library offers to consumers can be listed. This is the API variant of the model project (the report also has a runtimeElements variant and, for the JARs of sources and Javadoc, sourceElements and javadocElements; the full variant model is on Variant-Aware Resolution & Module Metadata):
./gradlew :tidepool-model:outgoingVariants --variant apiElements
> Task :tidepool-model:outgoingVariants
--------------------------------------------------
Variant apiElements
--------------------------------------------------
API elements for the 'main' feature.
Capabilities
- io.tidepool:tidepool-model:1.0.0 (default capability)
Attributes
- org.gradle.category = library
- org.gradle.dependency.bundling = external
- org.gradle.jvm.version = 21
- org.gradle.libraryelements = jar
- org.gradle.usage = java-api
Artifacts
- build/libs/tidepool-model-1.0.0.jar (artifactType = jar)
Secondary Variants (*)
--------------------------------------------------
Secondary Variant classes
--------------------------------------------------
Directories containing compiled class files for main.
Attributes
- org.gradle.category = library
- org.gradle.dependency.bundling = external
- org.gradle.jvm.version = 21
- org.gradle.libraryelements = classes
- org.gradle.usage = java-api
Artifacts
- build/classes/java/main (artifactType = java-classes-directory)
(*) Secondary variants are variants created via the Configuration#getOutgoing(): ConfigurationPublications API which also participate in selection, in addition to the configuration itself.
BUILD SUCCESSFUL in 926ms
1 actionable task: 1 executed
The attribute org.gradle.jvm.version = 21 is the Java target the project compiled for, which is what the version-mismatch error above compared with; the secondary variant classes is the directory that consumers in the same build compile against. A consumer decides this: a Groovy consumer, for instance, asks for classes and processed resources because AST transformations may need them at compile time.
Maven has only scopes, and compile is both api and implementation at once (the closest Maven mechanisms are provided, the optional flag and, for modules, requires transitive); see Maven Dependencies, Scopes & Mediation for the scopes and Dependency Management Fundamentals for the mapping table between scopes and Gradle configurations. In Ant every classpath is built by hand.
A custom source set: integration tests
Integration tests are the textbook use of a second source set: they need their own classpath (they may run against real files or servers), they are compiled against main, and they are a natural part of the project. The engine script above builds the source set by hand. Gradle also has the JVM Test Suite plugin (testing { suites { ... } }), which does the same in a handful of declarative lines and is what the user manual recommends today; it is the subject of Testing JVM Projects. The manual form still matters because it shows the objects that every other plugin creates: a source set, two configurations that extend the test ones, a Test task with the classes directory and the runtime classpath of the source set, and a connection to check.
// A hand-made source set for slower tests that read the real data file.
val integrationTest = sourceSets.register("integrationTest") {
compileClasspath += sourceSets.main.get().output
runtimeClasspath += sourceSets.main.get().output
}
configurations[integrationTest.get().implementationConfigurationName]
.extendsFrom(configurations.testImplementation.get())
configurations[integrationTest.get().runtimeOnlyConfigurationName]
.extendsFrom(configurations.testRuntimeOnly.get())
val integrationTestTask = tasks.register<Test>("integrationTest") {
description = "Runs the integration tests."
group = "verification"
testClassesDirs = integrationTest.get().output.classesDirs
classpath = integrationTest.get().runtimeClasspath
useJUnitPlatform()
shouldRunAfter(tasks.test)
}
tasks.check {
dependsOn(integrationTestTask)
}
// A hand-made source set for slower tests that read the real data file.
sourceSets {
integrationTest {
compileClasspath += sourceSets.main.output
runtimeClasspath += sourceSets.main.output
}
}
configurations {
integrationTestImplementation.extendsFrom testImplementation
integrationTestRuntimeOnly.extendsFrom testRuntimeOnly
}
def integrationTestTask = tasks.register('integrationTest', Test) {
description = 'Runs the integration tests.'
group = 'verification'
testClassesDirs = sourceSets.integrationTest.output.classesDirs
classpath = sourceSets.integrationTest.runtimeClasspath
useJUnitPlatform()
shouldRunAfter tasks.test
}
tasks.check {
dependsOn integrationTestTask
}
The Kotlin version registers the source set with sourceSets.register(...). The delegate form val integrationTest by sourceSets.registering { ... } that older material uses is deprecated since Gradle 9.6 and the build printed a deprecation warning for it before it was changed (see "Gradle 8.14 and Gradle 9"). The source set contributes compileIntegrationTestJava, processIntegrationTestResources, integrationTestClasses and the configurations integrationTestImplementation, integrationTestRuntimeOnly, integrationTestCompileClasspath and so on. Running only the new task, after removing its previous result with cleanIntegrationTest:
./gradlew cleanIntegrationTest :tidepool-engine:integrationTest
> Task :tidepool-engine:processResources NO-SOURCE
> Task :tidepool-engine:processIntegrationTestResources NO-SOURCE
> Task :tidepool-model:processResources NO-SOURCE
> Task :tidepool-model:compileJava UP-TO-DATE
> Task :tidepool-model:classes UP-TO-DATE
> Task :tidepool-model:jar UP-TO-DATE
> Task :tidepool-engine:compileJava UP-TO-DATE
> Task :tidepool-engine:classes UP-TO-DATE
> Task :tidepool-engine:cleanIntegrationTest
> Task :tidepool-engine:compileIntegrationTestJava UP-TO-DATE
> Task :tidepool-engine:integrationTestClasses UP-TO-DATE
> Task :tidepool-engine:integrationTest
BUILD SUCCESSFUL in 1s
6 actionable tasks: 2 executed, 4 up-to-date
and the results of the two Test tasks go to separate directories, named after the tasks:
tidepool-engine/build/test-results/integrationTest/TEST-io.tidepool.engine.StationReaderIT.xml
tidepool-engine/build/test-results/integrationTest/binary/output-events.bin
tidepool-engine/build/test-results/integrationTest/binary/results-generic.bin
tidepool-engine/build/test-results/test/TEST-io.tidepool.engine.TideEngineTest.xml
tidepool-engine/build/test-results/test/binary/output-events.bin
tidepool-engine/build/test-results/test/binary/results-generic.bin
shouldRunAfter(tasks.test) orders the two tasks when both are requested without making the slow one a prerequisite of the fast one, and tasks.check { dependsOn(...) } is what makes ./gradlew build run the integration tests (the integrationTest line in the --dry-run listing above). Forgetting the second part is the usual bug: the task exists, the tests pass when you run it, and no CI build ever runs it.
Source: Gradle User Manual: Configuring integration tests, Defining new source sets, Some other simple source set examples
The Maven way is the Failsafe plugin bound to integration-test and verify (see Maven Integration Testing with Failsafe); in Ant it is a second <javac> and <junitlauncher> target.
Packaging
The jar task packs the classes and the resources of the main source set; assemble depends on it, so every build produces the JAR. The name is <archiveBaseName>-<archiveVersion>.jar, where the base name defaults to the project name and the version to the project version, so the model of this build is tidepool-model-1.0.0.jar. The directory and the base name are properties of the base extension (base.libsDirectory, base.archivesName, base.distsDirectory); the old project properties archivesBaseName, libsDirName and distsDirName are gone (see "Gradle 8.14 and Gradle 9").
The manifest and the archive contents
Every Jar, War and Ear task has a manifest property. The model script sets two attributes with the values of the project; the manifest of the resulting JAR is
Manifest-Version: 1.0
Implementation-Title: tidepool-model
Implementation-Version: 1.0.0
and the file list of the JAR (class files, directories and the manifest, with the fixed time stamp explained below):
Archive: tidepool-model/build/libs/tidepool-model-1.0.0.jar
Length Date Time Name
--------- ---------- ----- ----
0 02-01-1980 00:00 META-INF/
94 02-01-1980 00:00 META-INF/MANIFEST.MF
0 02-01-1980 00:00 io/
0 02-01-1980 00:00 io/tidepool/
0 02-01-1980 00:00 io/tidepool/model/
1660 02-01-1980 00:00 io/tidepool/model/Constituent.class
1328 02-01-1980 00:00 io/tidepool/model/Height.class
2259 02-01-1980 00:00 io/tidepool/model/Station.class
1750 02-01-1980 00:00 io/tidepool/model/TideEvent.class
--------- -------
7091 9 files
Class-Path and Main-Class attributes go into the manifest the same way, but for a program the application plugin writes start scripts and a lib directory (below), which is the form that works with any set of dependencies. Gradle writes Manifest-Version: 1.0 itself. A library that wants to be usable as an automatic module without a module-info.java should add Automatic-Module-Name, because the module name derived from the file name is unstable.
Sources and Javadoc JARs
java { withSourcesJar() } and java { withJavadocJar() } add the tasks sourcesJar and javadocJar, attach them to assemble, and publish them as the variants sourceElements and javadocElements, which is what Maven Central expects of a release (see Publishing). The libs directory of the model after a build:
tidepool-model-1.0.0-javadoc.jar
tidepool-model-1.0.0-sources.jar
tidepool-model-1.0.0.jar
0 02-01-1980 00:00 io/
0 02-01-1980 00:00 io/tidepool/
0 02-01-1980 00:00 io/tidepool/model/
182 02-01-1980 00:00 io/tidepool/model/Constituent.java
118 02-01-1980 00:00 io/tidepool/model/Height.java
231 02-01-1980 00:00 io/tidepool/model/Station.java
163 02-01-1980 00:00 io/tidepool/model/TideEvent.java
--------- -------
719 9 files
The second listing is the end of the content of the sources JAR (the last rows of unzip -l). The Javadoc JAR contains the javadoc task output, with the -Xdoclint:all,-missing setting of the script.
Reproducible archives
Since Gradle 9.0.0 all archive tasks (Jar, War, Ear, Zip, Tar) produce reproducible archives by default: the order of the entries is deterministic, every entry has a fixed time stamp (the first of February 1980 for ZIP-based archives such as JARs, as in the unzip -l output above, and the second of January 1970 for TAR archives, which tar -tvf shows for the distTar output of this build), directories have the permissions 0755 and files 0644. That is why the same sources give the same JAR. A hash taken after a build, and again after clean and a rebuild two seconds later:
875ec791c2960e71acd6235e73b2349abf31acb4067b0dbb70a502191b79a7fd tidepool-model/build/libs/tidepool-model-1.0.0.jar
875ec791c2960e71acd6235e73b2349abf31acb4067b0dbb70a502191b79a7fd tidepool-model/build/libs/tidepool-model-1.0.0.jar
The two hashes are identical. Builds that depend on the old behaviour (file order from the file system, real time stamps and permissions) can restore it per task with isReproducibleFileOrder = false, isPreserveFileTimestamps = true and useFileSystemPermissions() (the properties are called reproducibleFileOrder and preserveFileTimestamps in the Groovy DSL). In Gradle 8.14 those archives were not reproducible unless the build opted in (not run here); the Maven counterpart is project.build.outputTimestamp, see Maven Security & Reproducible Builds.
A single executable JAR (fat JAR, uber JAR)
Gradle has no built-in fat JAR; the standard answer is a Jar task that unpacks the runtime classpath into the archive. The CLI script above contains it, because the way it handles the runtime classpath is the part that breaks under the configuration cache: the classpath must be a lazy Provider (or a closure) that is evaluated at execution time, not a configurations.runtimeClasspath.get() call made while the build is being configured. The dependsOn(configurations.runtimeClasspath) line is not optional: zipTree of a JAR file carries no task dependency, so without it Gradle does not build the JARs of the other two projects first, and after a clean the task fails with Cannot expand ZIP '…/tidepool-engine-1.0.0.jar' as it does not exist (reproduced on 9.8.1). The manual’s uber JAR example has the same line.
tasks.register<Jar>("fatJar") {
description = "Assembles a single executable JAR."
group = "build"
archiveClassifier = "all"
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
manifest {
attributes("Main-Class" to application.mainClass)
}
from(sourceSets.main.map { it.output })
dependsOn(configurations.runtimeClasspath)
from(configurations.runtimeClasspath.map { classpath ->
classpath.map { if (it.isDirectory) it else zipTree(it) }
})
}
tasks.register('fatJar', Jar) {
description = 'Assembles a single executable JAR.'
group = 'build'
archiveClassifier = 'all'
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
manifest {
attributes('Main-Class': application.mainClass)
}
from(sourceSets.main.output)
dependsOn(configurations.runtimeClasspath)
from {
configurations.runtimeClasspath.collect { it.directory ? it : zipTree(it) }
}
}
./gradlew clean :tidepool-cli:fatJar
> Task :clean
> Task :tidepool-engine:clean
> Task :tidepool-model:clean
> Task :tidepool-engine:processResources NO-SOURCE
> Task :tidepool-cli:clean
> Task :tidepool-model:processResources NO-SOURCE
> Task :tidepool-cli:processResources NO-SOURCE
> Task :tidepool-model:compileJava
> Task :tidepool-model:classes
> Task :tidepool-model:jar
> Task :tidepool-engine:compileJava
> Task :tidepool-engine:classes
> Task :tidepool-engine:jar
> Task :tidepool-cli:compileJava
warning: [processing] No processor claimed any of these annotations: /picocli.CommandLine.Command,/picocli.CommandLine.Parameters
1 warning
> Task :tidepool-cli:classes
> Task :tidepool-cli:fatJar
BUILD SUCCESSFUL in 482ms
10 actionable tasks: 10 executed
java -Duser.language=en -Duser.country=US -jar tidepool-cli/build/libs/tidepool-cli-1.0.0-all.jar data/stations.csv SF
San Francisco: -0.869 m
The JAR holds the classes of the CLI, of the other two projects and of the three libraries (unzip -l counts 758 class files), and the manifest names io.tidepool.cli.Main. archiveClassifier is the right property for the -all suffix (not archiveAppendix), because the classifier is what publishing uses to tell artifacts apart. duplicatesStrategy = DuplicatesStrategy.EXCLUDE keeps the first copy of a file that appears in several JARs, which is how files such as META-INF/LICENSE collide. The recipe copies files from JARs without merging them, so it breaks on libraries that use META-INF/services files, signed JARs, or multi-release JARs; those need the Shadow plugin (a third-party plugin), or the Maven Shade plugin on the Maven side. The Maven comparison, with relocation and service-file merging, is on Uber JARs with Shade & Assembly, and the Ant one (<zipgroupfileset>) is in Packaging Archives with Ant. The question to ask first is whether a fat JAR is needed at all; the next section’s distribution is usually the better artifact for a command line program.
Applications: the application plugin
The application plugin turns a Java project into a program. The only mandatory setting is the main class. It adds the run task for development, and the tasks that build a distribution: startScripts (generates a Unix shell script and a Windows batch file), distZip and distTar (archives of the scripts, the project JAR and its runtime dependencies), and installDist (the same content copied into build/install/<name>). The settings of the application { ... } block that the CLI uses are mainClass, applicationName (the name of the scripts and the distribution; the default is the project name) and applicationDefaultJvmArgs (the JVM arguments of run and the scripts). Others are executableDir (the bin directory name), mainModule (a main class inside a Java module, which makes the scripts use the module path), and applicationDistribution, a copy specification for extra files. The distributions { main { contents { ... } } } block of the CLI script adds the data file to the distribution.
Running during development
run is a JavaExec task with the runtime classpath of main. Arguments go through --args, which keeps the command line free of Gradle’s own option parsing:
./gradlew -q :tidepool-cli:run --args="../data/stations.csv BOS"
Boston: -0,662 m
The working directory of run is the project directory of the CLI, which is why the data file is ../data/stations.csv. A failing program makes the build fail with the exit code of the process, and the output of the program comes before Gradle’s message:
./gradlew -q :tidepool-cli:run --args="../data/stations.csv ZZ"
Unknown station: ZZ
FAILURE: Build failed with an exception.
* What went wrong:
Execution failed for task ':tidepool-cli:run' (registered by plugin 'org.gradle.application').
> Process 'command '/Library/Java/JavaVirtualMachines/jdk-26.jdk/Contents/Home/bin/java'' finished with non-zero exit value 1
* Try:
> Run with --stacktrace option to get the stack trace.
> Run with --info or --debug option to get more log output.
> Run with --scan to get full insights from a Build Scan (powered by Develocity).
> Get more help at https://help.gradle.org.
BUILD FAILED in 507ms
(The first line is the program’s own message on the error stream.) ./gradlew run --debug-jvm starts the JVM suspended with the debug agent listening, which is how an IDE debugger attaches. run can be reconfigured like any task, for example tasks.run { args("..."); workingDir = ...; jvmArgs(...) }. With a toolchain configured in the java extension (see Gradle Toolchains), run uses the toolchain’s JVM, not the one that runs Gradle.
The distribution and the start scripts
installDist produces a directory that can be copied anywhere, and it contains no Gradle:
./gradlew clean :tidepool-cli:installDist
> Task :clean
> Task :tidepool-cli:clean
> Task :tidepool-cli:processResources NO-SOURCE
> Task :tidepool-engine:clean
> Task :tidepool-engine:processResources NO-SOURCE
> Task :tidepool-model:clean
> Task :tidepool-model:processResources NO-SOURCE
> Task :tidepool-model:compileJava
> Task :tidepool-model:classes
> Task :tidepool-model:jar
> Task :tidepool-engine:compileJava
> Task :tidepool-engine:classes
> Task :tidepool-engine:jar
> Task :tidepool-cli:compileJava
warning: [processing] No processor claimed any of these annotations: /picocli.CommandLine.Command,/picocli.CommandLine.Parameters
1 warning
> Task :tidepool-cli:classes
> Task :tidepool-cli:jar
> Task :tidepool-cli:startScripts
> Task :tidepool-cli:installDist
BUILD SUCCESSFUL in 601ms
12 actionable tasks: 12 executed
tidepool-cli/build/install
tidepool-cli/build/install/tidepool
tidepool-cli/build/install/tidepool/bin
tidepool-cli/build/install/tidepool/bin/tidepool
tidepool-cli/build/install/tidepool/bin/tidepool.bat
tidepool-cli/build/install/tidepool/data
tidepool-cli/build/install/tidepool/data/stations.csv
tidepool-cli/build/install/tidepool/lib
tidepool-cli/build/install/tidepool/lib/commons-codec-1.19.0.jar
tidepool-cli/build/install/tidepool/lib/commons-csv-1.14.1.jar
tidepool-cli/build/install/tidepool/lib/commons-io-2.20.0.jar
tidepool-cli/build/install/tidepool/lib/picocli-4.7.7.jar
tidepool-cli/build/install/tidepool/lib/tidepool-cli-1.0.0.jar
tidepool-cli/build/install/tidepool/lib/tidepool-engine-1.0.0.jar
tidepool-cli/build/install/tidepool/lib/tidepool-model-1.0.0.jar
The lib directory holds the project JAR, the JARs of the two other projects and the libraries from the runtime classpath, the bin directory holds the two scripts, and data is the file added by the distributions block. distZip packs exactly this tree below a directory named after the distribution and its version:
Archive: tidepool-cli/build/distributions/tidepool-1.0.0.zip
Length Date Time Name
--------- ---------- ----- ----
0 02-01-1980 00:00 tidepool-1.0.0/
0 02-01-1980 00:00 tidepool-1.0.0/lib/
4150 02-01-1980 00:00 tidepool-1.0.0/lib/tidepool-cli-1.0.0.jar
3150 02-01-1980 00:00 tidepool-1.0.0/lib/tidepool-engine-1.0.0.jar
417640 02-01-1980 00:00 tidepool-1.0.0/lib/picocli-4.7.7.jar
4171 02-01-1980 00:00 tidepool-1.0.0/lib/tidepool-model-1.0.0.jar
60964 02-01-1980 00:00 tidepool-1.0.0/lib/commons-csv-1.14.1.jar
563971 02-01-1980 00:00 tidepool-1.0.0/lib/commons-io-2.20.0.jar
374716 02-01-1980 00:00 tidepool-1.0.0/lib/commons-codec-1.19.0.jar
0 02-01-1980 00:00 tidepool-1.0.0/bin/
8940 02-01-1980 00:00 tidepool-1.0.0/bin/tidepool
3256 02-01-1980 00:00 tidepool-1.0.0/bin/tidepool.bat
0 02-01-1980 00:00 tidepool-1.0.0/data/
239 02-01-1980 00:00 tidepool-1.0.0/data/stations.csv
--------- -------
1441197 14 files
The script sets the CLASSPATH to the JARs of lib in the order of the runtime classpath, takes the default JVM options from the application block, lets users add options through two environment variables (JAVA_OPTS and, here, TIDEPOOL_OPTS, which is the upper-cased application name with _OPTS), finds Java through JAVA_HOME or the java on the PATH, and passes all arguments to the main class. The relevant lines of the generated Unix script:
# Add default JVM options here. You can also use JAVA_OPTS and TIDEPOOL_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx256m"'
Running the installed script from the distribution directory, with the data file that ships in it. The JVM options of the first run set an English locale through JAVA_OPTS, the same mechanism a user would use for a heap setting; the second run shows the program’s own error path and its exit status:
JAVA_OPTS="-Duser.language=en -Duser.country=US" build/install/tidepool/bin/tidepool data/stations.csv HNL
build/install/tidepool/bin/tidepool data/stations.csv XX; echo "exit=$?"
Honolulu: -0.112 m
Unknown station: XX
exit=1
(Both lines of program output were produced by those two commands in this order; the third line is the exit status that the shell printed.) The start scripts are generated from templates under the Apache 2.0 licence, and the startScripts task accepts custom templates. The Windows template was reworked in Gradle 9.5.0 (not run here, because no Windows machine was involved), so a build that customizes it or calls gradlew.bat from another batch file should read the upgrade notes.
Source: Gradle User Manual: The Application Plugin, Tasks, The distribution, Customizing start script generation, Application extension
The Maven counterpart is a runnable JAR with a lib directory built by the JAR and dependency plugins, or the Assembly plugin (see Maven Packaging: JARs, WARs & Manifests); in Ant it is a <jar> with a Class-Path plus a hand-written script (see Packaging Archives with Ant). The shared view of publishing and distribution formats is on Repositories & Publishing Across Tools.
Web applications, Groovy and Scala, and Java modules
These three topics are plugins and settings that follow the same pattern as the rest of the page, so each is one paragraph here; none of the following was run for this page (not run here), and each statement comes from the user manual.
The war plugin extends java with a war task that assembles a WAR: the content of src/main/webapp goes to the root, compiled classes to WEB-INF/classes and the runtime classpath to WEB-INF/lib. Dependencies that the servlet container provides are declared in providedCompile (visible to compilation and tests, excluded from the WAR) or providedRuntime; both work transitively, which is why a provided dependency’s own dependencies are also left out of the WAR. These configurations are the old providedCompile of the Gradle 1.x books, still present; since Gradle 7.0 they cannot be consumed by other projects. Since Gradle 9.0 applying war together with java or ear builds all the archives during assemble (before 9.0 the WAR replaced the JAR), and the war project convention is gone, so the settings live on the war task itself (webAppDirectory, webXml, webInf { ... }). The Maven counterpart is the packaging type war (see Maven Packaging: JARs, WARs & Manifests), and a JSP/Struts project is described on the pages of the web section.
The groovy and scala plugins extend java with src/main/groovy and src/main/scala source directories that may also contain Java, and compile both languages together (joint compilation). The Groovy and Scala source directories may contain Java files next to the Groovy or Scala ones, and the compile tasks compileGroovy and compileScala, which run after compileJava, compile such mixed sources in one go, so classes may depend on each other in both directions. The Java source directories may contain only Java, and Java code that must call Groovy or Scala code has to live in the Groovy or Scala tree. The doc tasks are groovydoc and scaladoc. Applying java-library as well gives the API and implementation separation to a Groovy or Scala library. A Groovy project declares a Groovy dependency in implementation; a Scala project sets scalaVersion (an incubating property, which makes the Scala SDK dependency implicit) or declares the Scala library itself. The languages and their toolchains are described on Groovy Compiling: groovyc & Joint Compilation and Scala sbt & Build Tools; the Groovy side of Gradle (the build language) is on Groovy DSL & Migrating to Kotlin.
Java modules (a module-info.java) need no extra plugin. With a module-info.java in a source set, Gradle puts the dependencies on the module path instead of the class path (java.modularity.inferModulePath, true by default; the java extension can switch it off), the application plugin can run a modular program if mainModule is set next to mainClass, and a library that is not a module can still be used as an automatic module. The classpath-versus-module-path distinction is explained for the language on Java Packages & Modules.
The configuration cache and the Java plugins
The core Java plugins are compatible with the configuration cache, and every build on this page ran with it on and stored or reused an entry without problems. The pitfalls are all in code that a build adds around the plugins:
-
Resolving a configuration while the build is configured (
configurations.runtimeClasspath.get().filesin the body of a task registration) makes the configuration phase slow and, with the cache on, produces a problem as soon as the result is used in a task. ThefatJartask reads the classpath lazily, throughmapin the Kotlin DSL and through a closure in the Groovy DSL (the closure is evaluated when the task runs). -
Reaching into
projectfrom a task action (project.copy,project.exec,project.file) is not allowed in a cached task; use the properties of the task, or an injected service (see Configuration Cache & Isolated Projects). -
A compiler argument built from a project property, such as
-Aproject=...in the CLI script, is read at configuration time and becomes part of the cached configuration. That is fine, because it is an input ofcompileJava(a different value gives a different up-to-date state).
Classic mistakes
-
sourceCompatibilityinstead ofrelease. The build compiles, then fails on the older JVM withNoSuchMethodError(run above). Useoptions.releaseor a toolchain. -
An implementation type in the public API. The consumers fail with "package … does not exist" (run above). Check the type against the
apirules, or turn the dependency intoapi. -
A new
Testtask that is not attached tocheck. The tests exist and nobody runs them. Attach withtasks.check { dependsOn(...) }. -
The old configuration names.
compile,runtime,testCompileandtestRuntimewere removed in Gradle 7.0. The error isCould not find method compile() for arguments ...in the Groovy DSL and an unresolved reference in the Kotlin DSL; the replacements areapi,implementation,compileOnly,runtimeOnly,testImplementationandtestRuntimeOnly. -
A fat JAR that fails with
Invalid signature file digest(a classic JVM error; not reproduced here). A signed dependency has been unpacked into the archive. ExcludeMETA-INF/*.SF,META-INF/*.DSAandMETA-INF/*.RSAin thefatJartask, or do not build a fat JAR. -
A processor on the wrong classpath. An annotation processor in
implementationorcompileOnlyis not run by Gradle (Gradle ignores processors on the compile classpath); declare it inannotationProcessor. -
A version mismatch between projects. The "only compatible with JVM runtime version 21 or newer" message (run above) is the build telling you that a consumer targets an older Java than a library. Raise the consumer, lower the library’s release, or change both through one convention plugin (see Convention Plugins & Sharing Build Logic).
-
Running the program with the wrong working directory.
runstarts in the project directory of the application, not in the root of the build. The relative path of the data file differs betweenrun, the installed script and the IDE.
Gradle 8.14 and Gradle 9
The Java plugins are the same family in both lines. The column for 8.14 comes from the official documentation and release notes (not run here); the column for 9.8.1 is what was run above or what the 9.x notes say.
| Topic | Gradle 8.14 | Gradle 9.8.1 |
|---|---|---|
JVM that runs Gradle |
Older JVMs still run the daemon; the Java 17 requirement is announced in the 8.x upgrade notes. |
Java 17 or later for the daemon (9.0.0). The compiled code can still target older versions with |
Newest Java that Gradle supports for compiling and running |
Java 24 (8.14). |
Java 25 since 9.1.0, Java 26 since 9.4.0 and Java 27 since 9.8.0 (see the table on Versions, Compatibility & Release History). |
|
The conventions of the |
The Conventions API is removed (9.0.0). Use the |
Archive tasks |
Not reproducible unless the build opts in. |
Reproducible by default: fixed order, time stamps and permissions (9.0.0), run above. |
|
Deprecated since 7.0; still works with a warning. |
Removed (9.0.0). |
|
Deprecated since 8.11. |
Removed (9.0.0). Use the |
|
|
|
Kotlin DSL delegates such as |
Work. |
Deprecated since 9.6.0, removal planned for Gradle 10 (run above: the build printed a deprecation warning); use |
|
Unchanged. |
The setter is a no-op and deprecated, and the Windows template was reworked (9.5.0; not run here). |
Source: Gradle User Manual: Upgrading to Gradle 9.0.0, removal of conventions, Upgrading within Gradle 8.x, java plugin conventions, application plugin conventions, Upgrading from Gradle 7.x to 8.0, JavaApplication API cleanup, Upgrading within Gradle 9.x.y, Kotlin DSL delegated properties, Compatibility matrix
Equivalents in Ant and Maven
| Gradle | Maven | Ant |
|---|---|---|
Source sets ( |
One main and one test tree ( |
Whatever the |
|
|
|
|
Scopes only: |
Classpaths written by hand. |
Compile avoidance and incremental compilation |
The compiler plugin detects changes and recompiles the module. |
The |
|
|
|
Custom source set plus a |
Failsafe plugin; see Maven Integration Testing with Failsafe. |
Another |
|
Jar, source and Javadoc plugins; see Maven Packaging: JARs, WARs & Manifests. |
|
|
No core equivalent; the Assembly plugin or |
|
Fat JAR task |
Shade or Assembly plugin; see Uber JARs with Shade & Assembly. |
|
|
Packaging type |
|
The general comparison across the three tools is on Comparing Ant, Maven & Gradle.
Where the books are outdated
Gradle in Action (Muschko, Manning 2014) was written for Gradle 1.7 and treats the Java plugin conventions, custom layouts and the War plugin in its chapters on the first builds and on the web (chapters 3 and 7). What its examples use and what replaced it:
-
The configurations
compile,runtime,testCompile,testRuntime. Removed in Gradle 7.0. Useimplementation,api,compileOnly,runtimeOnly,testImplementationandtestRuntimeOnly. -
Project-level
sourceCompatibility = 1.7. The convention was deprecated in 8.2 and removed in 9.0.0. Usejava { toolchain { ... } }, oroptions.releaseon the compile task. -
mainClassName,archivesBaseName,jar.archiveName,jar.baseName.mainClassNamewas removed in 8.0 in favour ofmainClass;archiveName,baseNameand the other old archive task properties were removed in 8.0 in favour ofarchiveFileNameandarchiveBaseName; the project propertyarchivesBaseNamewent in 9.0.0 (base.archivesName). -
A single compile classpath that exposes everything. There was no
api/implementationsplit; it came with thejava-libraryplugin, together with compile avoidance and the incremental compiler. -
apply plugin: 'java'. Theplugins { }block is the current form, and it is the only form the Kotlin DSL gives type-safe accessors for. -
The Jetty plugin (
jettyRun) for web applications. Removed in Gradle 4.0; the third-party Gretty plugin covers that need. The War plugin itself, withprovidedCompile, is current. -
Groovy and Scala plugins and joint compilation (chapter 11). The plugins are still there with the same layout; Scala builds now declare the Scala version with
scalaVersionor the Scala library dependency. -
Hand-made source sets for integration tests. Still valid, and shown above, but the JVM Test Suite plugin is the recommended form.
Source: Gradle User Manual: Upgrading from Gradle 6.x to 7.0, removal of compile and runtime configurations, Upgrading from Gradle 7.x to 8.0, JavaApplication API cleanup, AbstractArchiveTask API cleanup, Upgrading to Gradle 9.0.0, removal of conventions, Gradle 4.0 release notes, Gradle in Action (Manning)
Where to go next
-
Testing JVM Projects: the
Testtask, JUnit Platform, test suites, fixtures and JaCoCo. -
Gradle Toolchains: the Java version of the compiler, the tests and the program.
-
Declaring Dependencies & Configurations and Version Catalogs: the dependency side of the build files above.
-
Publishing: publishing the JARs, the POM and the signatures.
-
Multi Project Builds and Convention Plugins & Sharing Build Logic: avoiding the copy of the three
options.releaseblocks. -
Build Cache: reusing compile outputs across machines.