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

java-base

The java extension, and the rules that give every source set a compile task, a resources task and a classes task. It creates no source set of its own.

Plugin authors who want the Java model without the main and test source sets; build authors almost never apply it directly.

java

The source sets main and test, the tasks compileJava, processResources, classes, jar, javadoc, test and the other tasks of the table below, and the configurations implementation, compileOnly, runtimeOnly, annotationProcessor and their test counterparts.

A module that is compiled, tested and packed, but that is not consumed as a library by other projects.

java-library

Everything of java, plus the configurations api and compileOnlyApi, and a separation between the dependencies that consumers need to compile against and the ones that are an implementation detail. Consumers of a project dependency compile against its classes directory instead of its JAR.

Any project that other projects depend on, such as tidepool-model and tidepool-engine.

application

Everything of java, plus the distribution plugin and the tasks run, startScripts, installDist, distZip and distTar, and the application extension (main class, JVM arguments, script name).

A program with a main method, such as tidepool-cli.

war

Everything of java, plus the war task, src/main/webapp, the configurations providedCompile and providedRuntime, and a web software component.

Web applications for a servlet container (see below).

groovy, scala

Everything of java, plus compileGroovy and groovydoc, or compileScala and scaladoc, and joint compilation of the language with Java.

Projects written wholly or partly in those languages (see below).

java-platform, java-test-fixtures, jvm-test-suite, jvm-toolchains

A platform of constraints without code, the testFixtures source set, the testing { suites { ... } } DSL, and the toolchain support that every JVM plugin applies.

Each has its own page: Platforms, Constraints & Rich Versions, Testing JVM Projects and Gradle Toolchains.

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.

Kotlin DSL: settings.gradle.kts
dependencyResolutionManagement {
    repositoriesMode = RepositoriesMode.FAIL_ON_PROJECT_REPOS
    repositories {
        mavenCentral()
    }
}

rootProject.name = "tidepool"

include("tidepool-model", "tidepool-engine", "tidepool-cli")
Groovy DSL: settings.gradle
dependencyResolutionManagement {
    repositoriesMode = RepositoriesMode.FAIL_ON_PROJECT_REPOS
    repositories {
        mavenCentral()
    }
}

rootProject.name = 'tidepool'

include 'tidepool-model', 'tidepool-engine', 'tidepool-cli'
gradle.properties
org.gradle.configuration-cache=true
group=io.tidepool
version=1.0.0
gradle/libs.versions.toml (the same for both DSLs; highlight.js has no TOML grammar, so it is shown as plain text)
[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.

Kotlin DSL: tidepool-model/build.gradle.kts
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)
        }
    }
}
Groovy DSL: tidepool-model/build.gradle
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).

Kotlin DSL: tidepool-engine/build.gradle.kts
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)
}
Groovy DSL: tidepool-engine/build.gradle
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").

Kotlin DSL: tidepool-cli/build.gradle.kts
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) }
    })
}
Groovy DSL: tidepool-cli/build.gradle
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

classes/java/<sourceSet>

The compiled classes of a source set (classes/java/main, classes/java/test).

resources/<sourceSet>

The processed resources of a source set.

generated/sources/annotationProcessor/java/<sourceSet>

Java sources that annotation processors generated while compiling the source set.

generated/sources/headers/java/<sourceSet>

JNI headers, if the compiler is asked for them.

libs

JARs: jar, and with the options of the tidepool-model script -sources.jar and -javadoc.jar too.

distributions

The archives of the application plugin.

docs/javadoc

The output of the javadoc task. Together with test-results and reports these directories are properties of the java and base extensions (docsDir, testResultsDir, testReportDir, libsDirectory).

test-results, reports, tmp

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

compileNameJava (type JavaCompile), processNameResources (Copy), nameClasses (a lifecycle task). main is the exception: its tasks are compileJava, processResources and classes.

Declarable configurations

nameImplementation, nameCompileOnly, nameRuntimeOnly, nameAnnotationProcessor. testImplementation extends implementation, and testRuntimeOnly extends runtimeOnly.

Resolvable configurations

nameCompileClasspath (extends nameCompileOnly and nameImplementation), nameRuntimeClasspath (extends nameRuntimeOnly and nameImplementation).

Source set properties

java.srcDirs (default src/name/java), resources.srcDirs, output.classesDirs, output.resourcesDir, compileClasspath, annotationProcessorPath, runtimeClasspath, allJava, allSource.

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:

Kotlin DSL
sourceSets {
    main {
        java {
            setSrcDirs(listOf("src/java"))
        }
    }
}
Groovy DSL
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).

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:

flowchart LR compileJava --> classes processResources --> classes classes --> jar classes --> javadoc jar --> assemble classes --> compileTestJava compileTestJava --> testClasses processTestResources --> testClasses testClasses --> test test --> check check --> build assemble --> build
Task Type and depends on What it does

compileJava

JavaCompile; all tasks that contribute to the compile classpath, including the jar or classes tasks of projects on it

Compiles src/main/java with the JDK compiler.

processResources

ProcessResources

Copies src/main/resources to build/resources/main.

classes

lifecycle; compileJava, processResources

Everything needed to run the main classes. Other plugins attach further compile tasks to it.

jar

Jar; classes

Packs the classes and resources of main.

javadoc

Javadoc; classes

Runs the javadoc tool on the main sources.

compileTestJava, processTestResources, testClasses

JavaCompile, Copy, lifecycle

The same three steps for test; compileTestJava needs classes.

test

Test; testClasses and the test runtime classpath

Runs the unit tests (see Testing JVM Projects).

clean, cleanTaskName

Delete

Deletes build/, or the files that one task created (cleanJar, cleanTest).

assemble, check, build

lifecycle (from the base plugin)

assemble produces all archives, check runs all verification tasks, build does both.

buildConfigName

task rule

Builds the artifacts attached to a configuration (build<ConfigurationName>).

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 (java { toolchain { languageVersion = ... } })

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.

options.release = 17 on the compile task

Passes --release 17 to javac: language level 17, class file version 61, and the API of Java 17 only.

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.

sourceCompatibility and targetCompatibility in the java extension

Pass -source and -target: language level and class file version, but the compiler still sees the API of the JDK that runs it.

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

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:

Kotlin DSL
java {
    sourceCompatibility = JavaVersion.VERSION_17
    targetCompatibility = JavaVersion.VERSION_17
}
Groovy DSL
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:

Kotlin DSL
tasks.withType<JavaCompile>().configureEach {
    options.release = 17
}
Groovy DSL
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.

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.

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

api

Yes

Yes, transitively

implementation

Yes

No (runtime classpath only)

compileOnly

Yes

No

compileOnlyApi

Yes

Yes (for a dependency that is needed to compile against the library but not at runtime, such as an annotation JAR)

runtimeOnly

No

No (runtime classpath of consumers only)

In the example build the structure is:

flowchart TD cli["tidepool-cli (application)"] engine["tidepool-engine (java-library)"] model["tidepool-model (java-library)"] csv["commons-csv"] picocli["picocli"] cli -->|implementation| engine cli -->|implementation| picocli engine -->|api| model engine -->|implementation| csv

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.

Kotlin DSL: the hand-made source set
// 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)
}
Groovy DSL: the hand-made source set
// 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.

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.

Kotlin DSL
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) }
    })
}
Groovy DSL
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.

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().files in 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. The fatJar task reads the classpath lazily, through map in the Kotlin DSL and through a closure in the Groovy DSL (the closure is evaluated when the task runs).

  • Reaching into project from 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 of compileJava (a different value gives a different up-to-date state).

Classic mistakes

  • sourceCompatibility instead of release. The build compiles, then fails on the older JVM with NoSuchMethodError (run above). Use options.release or a toolchain.

  • An implementation type in the public API. The consumers fail with "package …​ does not exist" (run above). Check the type against the api rules, or turn the dependency into api.

  • A new Test task that is not attached to check. The tests exist and nobody runs them. Attach with tasks.check { dependsOn(...) }.

  • The old configuration names. compile, runtime, testCompile and testRuntime were removed in Gradle 7.0. The error is Could not find method compile() for arguments ... in the Groovy DSL and an unresolved reference in the Kotlin DSL; the replacements are api, implementation, compileOnly, runtimeOnly, testImplementation and testRuntimeOnly.

  • 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. Exclude META-INF/*.SF, META-INF/*.DSA and META-INF/*.RSA in the fatJar task, or do not build a fat JAR.

  • A processor on the wrong classpath. An annotation processor in implementation or compileOnly is not run by Gradle (Gradle ignores processors on the compile classpath); declare it in annotationProcessor.

  • 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. run starts in the project directory of the application, not in the root of the build. The relative path of the data file differs between run, 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 release or toolchains. The clients (wrapper, Tooling API, TestKit) still run on Java 8.

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).

sourceCompatibility, targetCompatibility, archivesBaseName, mainClassName as project properties and conventions

The conventions of the java, application, base, war and ear plugins are deprecated since 8.2; access through the project works with a deprecation warning.

The Conventions API is removed (9.0.0). Use the java {}, application {}, base {} extensions and configure the war and ear tasks directly. mainClassName is older: it was removed in 8.0, mainClass replaced it.

Archive tasks

Not reproducible unless the build opts in.

Reproducible by default: fixed order, time stamps and permissions (9.0.0), run above.

jcenter()

Deprecated since 7.0; still works with a warning.

Removed (9.0.0).

Project.exec and Project.javaexec in custom run tasks

Deprecated since 8.11.

Removed (9.0.0). Use the JavaExec task or an injected ExecOperations.

war together with java or ear

assemble builds one artifact type.

assemble builds the JAR, the WAR and the EAR (9.0.0).

Kotlin DSL delegates such as val x by sourceSets.registering

Work.

Deprecated since 9.6.0, removal planned for Gradle 10 (run above: the build printed a deprecation warning); use val x = sourceSets.register("x").

CreateStartScripts.exitEnvironmentVar, Windows start script

Unchanged.

The setter is a no-op and deprecated, and the Windows template was reworked (9.5.0; not run here).

Equivalents in Ant and Maven

Gradle Maven Ant

Source sets (main, test, any number of custom ones)

One main and one test tree (src/main/java, src/test/java); other trees need plugins such as the build-helper plugin; see Maven Philosophy, Coordinates & Directory Layout.

Whatever the srcdir of each <javac> says.

compileJava and options.release

compiler:compile with maven.compiler.release.

<javac release="21"> (see Compiling Java with Ant).

java-library: api and implementation

Scopes only: compile is both; provided, runtime, optional.

Classpaths written by hand.

Compile avoidance and incremental compilation

The compiler plugin detects changes and recompiles the module.

The <javac> time-stamp comparison of each file.

annotationProcessor configuration

annotationProcessorPaths of the compiler plugin.

<compilerarg> and a processor path.

Custom source set plus a Test task

Failsafe plugin; see Maven Integration Testing with Failsafe.

Another <javac> and <junitlauncher> target.

jar, withSourcesJar(), withJavadocJar()

Jar, source and Javadoc plugins; see Maven Packaging: JARs, WARs & Manifests.

<jar>, <javadoc>; see Packaging Archives with Ant.

application: run, installDist, distZip

No core equivalent; the Assembly plugin or exec:java.

<java> for running; the distribution is hand-made (see Packaging Archives with Ant).

Fat JAR task

Shade or Assembly plugin; see Uber JARs with Shade & Assembly.

<jar> with <zipgroupfileset>.

war plugin, providedCompile

Packaging type war, scope provided.

<war>.

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. Use implementation, api, compileOnly, runtimeOnly, testImplementation and testRuntimeOnly.

  • Project-level sourceCompatibility = 1.7. The convention was deprecated in 8.2 and removed in 9.0.0. Use java { toolchain { ... } }, or options.release on the compile task.

  • mainClassName, archivesBaseName, jar.archiveName, jar.baseName. mainClassName was removed in 8.0 in favour of mainClass; archiveName, baseName and the other old archive task properties were removed in 8.0 in favour of archiveFileName and archiveBaseName; the project property archivesBaseName went in 9.0.0 (base.archivesName).

  • A single compile classpath that exposes everything. There was no api/implementation split; it came with the java-library plugin, together with compile avoidance and the incremental compiler.

  • apply plugin: 'java'. The plugins { } 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, with providedCompile, 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 scalaVersion or 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.

Where to go next