Declaring Dependencies & Configurations

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 on JDK 26 through the Gradle wrapper on 2026-10-16, on macOS, with the configuration cache enabled (org.gradle.configuration-cache=true in gradle.properties) and compiles the Java code with options.release = 21. Every example is compatible with the configuration cache unless the text says otherwise; the examples that fail on purpose were run in a scratch copy of the build and are labelled as failures. The Kotlin DSL is shown first and the Groovy DSL second wherever the syntax differs, and both versions were run, with the same output unless the text says that it differs. Paths of the scratch workspace are shortened to <work>; lines that were left out of an output are marked with ..., and the A web-based, searchable dependency report is available by adding the --scan option. hint that closes every dependencies report is left out. Gradle 8.14 was not available here: what differs in it is described from the official documentation in the section "Gradle 8.14 and Gradle 9" and is labelled "not run here".

A dependency is a statement that the code of this project needs the code of another module, project or file. What makes Gradle’s model different from Ant’s and Maven’s is the second half of the statement: where it is needed. Every dependency is declared on a named bucket, a configuration, and the build then asks Gradle to turn certain configurations into classpaths. Many of the problems that dependencies cause in a Gradle build are a dependency declared on the wrong bucket, or a bucket used for something it was not made for, so this page is about the buckets as much as about the dependencies. Repositories are on Repositories & Content Filtering, versions and the catalog on Version Catalogs, version constraints, rich versions and platforms on Platforms, Constraints & Rich Versions, what happens when two paths ask for different versions on Dependency Resolution, Conflicts & Capabilities, and the attributes by which a variant of a module is chosen on Variant-Aware Resolution & Module Metadata. The vocabulary that is common to the three tools (coordinates, the graph, scopes) is on Dependency Management Fundamentals; this page is the Gradle half of its section on scopes and configurations.

The running example

The example of the section is tidepool, a small tide prediction toolkit: a model library (tidepool-model), an engine library on top of it (tidepool-engine) and a command line application (tidepool-cli). Its layout, its settings file and how the projects find each other are on Multi Project Builds; the settings file declares Maven Central once, for every project, with repositoriesMode = RepositoriesMode.FAIL_ON_PROJECT_REPOS, so no build script below has a repositories block. The libraries live in the version catalog, which the Groovy and the Kotlin build share:

gradle/libs.versions.toml (shown as plain text, because highlight.js has no TOML grammar)
[versions]
commons-csv = "1.14.1"
picocli = "4.7.7"
annotations = "26.1.0"
auto-service = "1.1.1"
slf4j = "2.0.17"
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" }
jetbrains-annotations = { module = "org.jetbrains:annotations", version.ref = "annotations" }
auto-service = { module = "com.google.auto.service:auto-service", version.ref = "auto-service" }
auto-service-annotations = { module = "com.google.auto.service:auto-service-annotations", version.ref = "auto-service" }
slf4j-simple = { module = "org.slf4j:slf4j-simple", version.ref = "slf4j" }
junit-jupiter = { module = "org.junit.jupiter:junit-jupiter", version.ref = "junit" }
junit-platform-launcher = { module = "org.junit.platform:junit-platform-launcher", version.ref = "junit" }
gradle.properties
org.gradle.configuration-cache=true
group=io.tidepool
version=1.0.0
org.gradle.jvmargs=-Xmx1g -XX:MaxMetaspaceSize=512m

The org.gradle.jvmargs line only gives the daemon more room than the 512 MiB heap and 384 MiB metaspace it starts with when nothing is set; the wrapper properties (the checksum and the URL https://services.gradle.org/distributions/gradle-9.8.1-bin.zip) are on Gradle Wrapper.

These files are this page’s own setup for the examples, not an example taken from the manual.

Configurations and their three roles

A configuration is a named collection of dependencies, and also of the artifacts a project offers and of the rules by which they are selected. The same word covers three quite different jobs, and since Gradle 8.4 the user manual calls them roles:

Role (flag) Also called What it is for Example in the Java plugins

Declarable (canBeDeclared)

dependency scope, bucket

Collects the dependencies, dependency constraints and exclude rules that are written in the dependencies block. It is never resolved and never consumed itself.

implementation, api, runtimeOnly

Resolvable (canBeResolved)

consumer, classpath

The root of a dependency graph. Asking it for its files makes Gradle resolve what the declarable configurations it extends collected into a set of files. Dependencies are not declared on it directly.

compileClasspath, runtimeClasspath

Consumable (canBeConsumed)

producer, outgoing variant

What this project offers to the projects that depend on it: artifacts plus attributes that describe them. It is selected by another project’s resolution; it is not resolved here.

apiElements, runtimeElements

The three roles exist so that a build can say what it means. A dependency declared on implementation is a statement about this project; compileClasspath is the answer to "what does javac see?"; apiElements is the answer to "what does the project that depends on me see?". The three questions have different answers, and keeping the three jobs apart is how api can differ from implementation. Before the roles were made explicit, one configuration object could be all three, and the flags canBeResolved, canBeConsumed and canBeDeclared were the only way to tell (all three default to true, which the user manual labels "legacy, don’t use"). The role factory methods dependencyScope, resolvable and consumable arrived in Gradle 8.4 and create configurations whose role cannot be changed afterwards; in Gradle 9.8.1 they are still marked incubating in the Javadoc, and they are the way to create your own (see Your own configurations). Methods called on a configuration in a role they do not suit, such as resolving a declarable one, were a deprecation in the 8.x line and are an error since Gradle 9.0 (both are shown below).

In the tools that came before Gradle the same idea has other names. A Maven scope is an attribute of each dependency (compile, provided, runtime, test) that decides which classpaths the dependency joins and how it is inherited, and it is the thing that is published to consumers; see Maven Dependencies, Scopes & Mediation. An Ivy configuration is a named view of a module, and a dependency maps one configuration of mine to one of the dependency’s (conf="compile->default"); a Gradle configuration is closest to that, and Ivy’s extends is Gradle’s extendsFrom; see Ivy: Module Descriptors & Configurations. The section The same in Maven and in Ant lines the three up.

The three kinds of dependencies

Gradle distinguishes module dependencies (a published module found in a repository), project dependencies (another project of the same build) and file dependencies (files with no metadata). All three are written inside dependencies { }, as a configuration name followed by the notation: implementation("org.apache.commons:commons-csv:1.14.1"). In the Kotlin DSL implementation(...) is a generated accessor for the configuration that the Java plugin created; a configuration that your own script creates has none, and is written with a string ("tideTools"("...")) or through add. In the Groovy DSL the configuration name is a dynamic method.

Module dependencies

The notation of a module dependency is the string group:name:version, which are the same three coordinates as in a Maven POM (Gradle calls the artifactId the name). The optional parts follow it: group:name:version:classifier@extension. The forms below were run in a single-project build named notation.

Kotlin DSL: build.gradle.kts
plugins {
    `java-library`
}

dependencies {
    // A module dependency: group:name:version, with a reason and an exclusion
    implementation("org.apache.commons:commons-csv:1.14.1") {
        because("reads the stations file")
        exclude(group = "commons-codec", module = "commons-codec")
    }

    // Do not follow the transitive dependencies of this module
    runtimeOnly("org.slf4j:slf4j-simple:2.0.17") {
        isTransitive = false
    }

    // A classifier selects another artifact of the same module
    runtimeOnly("info.picocli:picocli:4.7.7:sources")

    // An artifact-only notation (@jar) ignores the module metadata: no transitive dependencies
    runtimeOnly("com.google.guava:guava:33.5.0-jre@jar")

    // File dependencies: files without any metadata
    implementation(files("libs/legacy.jar"))
    implementation(fileTree("tools") { include("*.jar") })
}

tasks.register("printClasspaths") {
    val classpaths: Map<String, FileCollection> = mapOf(
        "compileClasspath" to configurations.compileClasspath.get(),
        "runtimeClasspath" to configurations.runtimeClasspath.get(),
    )
    doLast {
        classpaths.forEach { (name, files) ->
            println("$name:")
            files.map { it.name }.sorted().forEach { println("  $it") }
        }
    }
}
Groovy DSL: build.gradle
plugins {
    id 'java-library'
}

dependencies {
    // A module dependency: group:name:version, with a reason and an exclusion
    implementation('org.apache.commons:commons-csv:1.14.1') {
        because 'reads the stations file'
        exclude group: 'commons-codec', module: 'commons-codec'
    }

    // Do not follow the transitive dependencies of this module
    runtimeOnly('org.slf4j:slf4j-simple:2.0.17') {
        transitive = false
    }

    // A classifier selects another artifact of the same module
    runtimeOnly 'info.picocli:picocli:4.7.7:sources'

    // An artifact-only notation (@jar) ignores the module metadata: no transitive dependencies
    runtimeOnly 'com.google.guava:guava:33.5.0-jre@jar'

    // File dependencies: files without any metadata
    implementation files('libs/legacy.jar')
    implementation fileTree('tools') { include '*.jar' }
}

tasks.register('printClasspaths') {
    Map<String, FileCollection> classpaths = [
        compileClasspath: configurations.compileClasspath,
        runtimeClasspath: configurations.runtimeClasspath,
    ]
    doLast {
        classpaths.each { name, files ->
            println "${name}:"
            files.collect { it.name }.sort().each { println "  ${it}" }
        }
    }
}

The printClasspaths task resolves the compile and the runtime classpath at execution time and prints the file names. It captures the two configurations as FileCollection values when the task is configured, and reads them only in the action: that is what makes it compatible with the configuration cache (the section Your own configurations shows what fails when the build script is touched in the action). The output is the same in both DSLs:

compileClasspath:
  commons-csv-1.14.1.jar
  commons-io-2.20.0.jar
  extra.jar
  legacy.jar
runtimeClasspath:
  commons-csv-1.14.1.jar
  commons-io-2.20.0.jar
  extra.jar
  guava-33.5.0-jre.jar
  legacy.jar
  picocli-4.7.7-sources.jar
  slf4j-simple-2.0.17.jar

What each of the lines of the build did can be read from these two outputs:

  • String notation, with because and exclude. commons-csv brings commons-io but not commons-codec, which the exclude(group = ..., module = ...) (Groovy: exclude group: ..., module: ...) removed from this dependency’s subtree; the because text is a documentation of intent that the dependencyInsight report prints (see Viewing and debugging dependencies). An exclusion applies to this declaration and to everything below it; excluding a module everywhere is a rule of the configuration, which is on Resolution Rules & Substitution.

  • isTransitive = false (Groovy: transitive = false) takes the module without any of its dependencies. slf4j-simple alone is in the runtime classpath, without slf4j-api that it would normally bring.

  • A classifier selects another artifact of the same module. info.picocli:picocli:4.7.7:sources resolves the file picocli-4.7.7-sources.jar, and the dependency report cannot tell it from a plain one: it only lists info.picocli:picocli:4.7.7.

  • @jar, the artifact-only notation, tells Gradle to take that artifact of the module and ignore the module’s metadata, so none of its transitive dependencies come with it. Guava is the clear case. Declared plainly it brings five more modules, as this report (run in a scratch build) shows:

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

    With @jar only guava-33.5.0-jre.jar is in the classpath, which is what the runtime classpath above shows. The artifact-only notation is occasionally what you want and often what causes a NoClassDefFoundError later; prefer isTransitive = false for the same effect with a name that says what it does.

The dependencies report of the runtime classpath is the other view of the same build:

runtimeClasspath - Runtime classpath of source set 'main'.
+--- org.apache.commons:commons-csv:1.14.1
|    \--- commons-io:commons-io:2.20.0
+--- org.slf4j:slf4j-simple:2.0.17
+--- info.picocli:picocli:4.7.7
\--- com.google.guava:guava:33.5.0-jre

The tree shows commons-io below commons-csv and no commons-codec, and neither the sources classifier nor the @jar suffix. The Groovy DSL printed the same.

The multi-string notation is deprecated

Older builds write the three coordinates as named arguments: implementation(group = "org.apache.commons", name = "commons-csv", version = "1.14.1") in the Kotlin DSL and implementation group: 'org.apache.commons', name: 'commons-csv', version: '1.14.1' in the Groovy DSL. That multi-string notation (the user manual also calls it the map notation) was deprecated in Gradle 9.1 and fails with an error in Gradle 10. The build still works in Gradle 9.8.1, and warns, in both DSLs (run with --warning-mode all and without the configuration cache, since a cache hit does not repeat configuration-time warnings):

Kotlin DSL
> Configure project :
Declaring dependencies using multi-string notation has been deprecated. This will fail with an error in Gradle 10. Please use single-string notation ("org.apache.commons:commons-csv:1.14.1") or DependencyFactory instead. Consult the upgrading guide for further information: https://docs.gradle.org/9.8.1/userguide/upgrading_version_9.html#dependency_multi_string_notation
	at Build_gradle._init_$lambda$0(build.gradle.kts:6)
	(Run with --stacktrace to get the full stack trace of this deprecation warning.)

> Task :dependencies

------------------------------------------------------------
Root project 'notation'
------------------------------------------------------------
Groovy DSL
> Configure project :
Build file '<work>/build.gradle': line 6
Declaring dependencies using multi-string notation has been deprecated. This will fail with an error in Gradle 10. Please use single-string notation ("org.apache.commons:commons-csv:1.14.1") or DependencyFactory instead. Consult the upgrading guide for further information: https://docs.gradle.org/9.8.1/userguide/upgrading_version_9.html#dependency_multi_string_notation
	at build_523e0ft5mz87vjihc7bhxcrt7$_run_closure1.doCall$original(<work>/build.gradle:6)
	(Run with --stacktrace to get the full stack trace of this deprecation warning.)

> Task :dependencies

------------------------------------------------------------
Root project 'notation'
------------------------------------------------------------

The replacement is the single string "org.apache.commons:commons-csv:1.14.1" (a classifier or extension goes into the string, targetConfiguration into the configuration block); the upgrade guide lists the translations.

Project dependencies

A dependency on another project of the same build names the project by its path: implementation(project(":tidepool-engine")) (Groovy: implementation project(':tidepool-engine')). The dependent project is built from the sources in the build and not downloaded; the task graph, the classpaths it produces, the type-safe accessors (projects.tidepoolEngine, which are enabled with the TYPESAFE_PROJECT_ACCESSORS feature preview) and the deprecation in Gradle 9.6 of passing a Project object as the notation are all on Multi Project Builds and are not repeated here. What matters on this page is that a project dependency is also declared on a configuration (api or implementation below) and that this choice is what the next sections are about. The dependencies report writes a project dependency declared on a declarable configuration as project tidepool-model, and a resolved one as project ':tidepool-model'.

Gradle 9.3 also deprecated a related habit. Declaring com.example:my-project:1.0.0 in the project whose own group, name and version are exactly those used to resolve to that project itself; in Gradle 10 it will be looked up in the repositories instead. Use project(...) for the project dependency.

File dependencies

A file dependency is a set of files added to a configuration with files(...) or fileTree(...). It has no coordinates, so it has no transitive dependencies, no version, nothing to resolve and nothing to publish: the user manual recommends declaring the files in a local repository on a file:// URL instead, and says outright that file dependencies are discouraged. They are what remains when a jar is not available from any repository, such as a vendor library checked into the repository. The notation build above has one of each, with libs/legacy.jar and tools/extra.jar, which were made with the jar tool for the occasion (their content is irrelevant). Both reached the compile and the runtime classpath (legacy.jar and extra.jar in the output above), because they were declared on implementation. In the dependencies report of the declarable configuration they look like this:

implementation - Implementation dependencies for the 'main' feature. (n)
+--- org.apache.commons:commons-csv:1.14.1 (n)
+--- unspecified (n)
\--- unspecified (n)

Two things are visible. The (n) marks a declarable configuration, whose tree cannot be resolved (see the annotations in Viewing and debugging dependencies), and the file dependencies are named unspecified, because they have no coordinates; they do not appear in the tree of the resolved runtimeClasspath above at all, even though their files are in the classpath. The report is therefore not the complete picture of a classpath that has file dependencies, and a build that has them needs to look at the files.

Two more facts from the user manual. A fileTree has no stable order, even on one machine, which changes the cache key of any task that takes the classpath as an input; files(...) with explicit names is stable. And when the files are produced by a task, the dependency must say so with builtBy, or a project that depends transitively on this one may not run the task first:

Kotlin DSL
dependencies {
    implementation(files(layout.buildDirectory.dir("classes")) {
        builtBy("compile")
    })
}
Groovy DSL
dependencies {
    implementation files(layout.buildDirectory.dir('classes')) {
        builtBy 'compile'
    }
}

This snippet is taken from the user manual and was not run here. gradleApi() and localGroovy() are file dependencies too, for the build’s own plugin code (see Binary Plugins).

Where the version comes from

The examples above spell the version out in the string. In a real build it comes from the version catalog (implementation(libs.commons.csv), as in the tidepool build files below), which Version Catalogs explains, and the version can be a range, a prefix, a strict constraint or an imported bill of materials, which Platforms, Constraints & Rich Versions explains. A catalog entry can take a configuration block exactly as a string can: implementation(libs.commons.csv) { because("reads the stations file") } is in the engine build below.

The configurations of the Java plugins

The java plugin creates the configurations for the main source set and for the test source set, and java-library adds api and compileOnlyApi to the main one. The application plugin applies java. All of them were printed from the build that ran on this page, by a task that lists every configuration of tidepool-engine (a java-library project) with its roles and what it extends:

Kotlin DSL
tasks.register("printConfigurations") {
    val rows: Provider<List<String>> = provider {
        configurations.sortedBy { it.name }.map { c ->
            val roles = listOfNotNull(
                "declarable".takeIf { c.isCanBeDeclared },
                "resolvable".takeIf { c.isCanBeResolved },
                "consumable".takeIf { c.isCanBeConsumed },
            ).joinToString("+")
            "${c.name} [$roles] extends ${c.extendsFrom.map { it.name }.sorted()}"
        }
    }
    inputs.property("rows", rows)
    doLast {
        rows.get().forEach { println(it) }
    }
}
Groovy DSL
tasks.register('printConfigurations') {
    Provider<List<String>> rows = provider {
        configurations.sort { it.name }.collect { c ->
            def roles = []
            if (c.canBeDeclared) roles << 'declarable'
            if (c.canBeResolved) roles << 'resolvable'
            if (c.canBeConsumed) roles << 'consumable'
            "${c.name} [${roles.join('+')}] extends ${c.extendsFrom*.name.sort()}".toString()
        }
    }
    inputs.property('rows', rows)
    doLast {
        rows.get().each { println it }
    }
}

The rows are computed in a provider and stored as a task input, so that the action does not touch the project. The output, the same for both DSLs:

annotationProcessor [declarable+resolvable] extends []
api [declarable] extends []
apiElements [consumable] extends [api, compileOnlyApi]
archives [consumable] extends []
compileClasspath [resolvable] extends [compileOnly, implementation]
compileOnly [declarable] extends [compileOnlyApi]
compileOnlyApi [declarable] extends []
default [consumable] extends [runtimeElements]
implementation [declarable] extends [api]
mainSourceElements [consumable] extends [implementation]
runtimeClasspath [resolvable] extends [implementation, runtimeOnly]
runtimeElements [consumable] extends [implementation, runtimeOnly]
runtimeOnly [declarable] extends []
testAnnotationProcessor [declarable+resolvable] extends []
testCompileClasspath [resolvable] extends [testCompileOnly, testImplementation]
testCompileOnly [declarable] extends [compileOnlyApi]
testImplementation [declarable] extends [implementation]
testResultsElementsForTest [consumable] extends []
testRuntimeClasspath [resolvable] extends [testImplementation, testRuntimeOnly]
testRuntimeOnly [declarable] extends [runtimeOnly]

Read together with the user manual’s tables, this is the complete picture:

Configuration Role Dependencies declared here are on…​ Maven scope in a published POM

api (java-library only)

declarable

the compile classpath of this project and of its consumers; the runtime classpath of both

compile

implementation

declarable

the compile and runtime classpath of this project; the runtime classpath of its consumers, not their compile classpath

runtime

compileOnly

declarable

only the compile classpath of this project (not the test classpath, not the runtime, not published)

none

compileOnlyApi (java-library only, Gradle 6.7)

declarable

the compile classpath of this project and of its consumers, and nothing at runtime

compile, see Dependencies in the published metadata

runtimeOnly

declarable

only the runtime classpath of this project and of its consumers

runtime

annotationProcessor

declarable and resolvable

the annotation processor path of the compiler

none

testImplementation, testCompileOnly, testRuntimeOnly

declarable

the compile and runtime classpath of the tests, the compile classpath of the tests, the runtime classpath of the tests; testImplementation extends implementation

none (tests are not published)

compileClasspath, runtimeClasspath, testCompileClasspath, testRuntimeClasspath

resolvable

Nothing is declared on them: they are the classpaths that javac and java (or the test JVM) are given

none

apiElements, runtimeElements

consumable

Nothing is declared on them: they are what a project that depends on this one selects (the java-api and java-runtime variants)

the compile and runtime entries of the POM are written from them

The diagram draws the main half of it as a graph. An arrow from A to B means that B extendsFrom A, which is why a dependency declared on api ends up in implementation, in compileClasspath, in runtimeClasspath and in both outgoing variants.

The configurations of a Java library project: the declarable configurations compileOnlyApi, api, compileOnly, implementation, runtimeOnly and annotationProcessor, the resolvable compileClasspath and runtimeClasspath, and the consumable apiElements and runtimeElements, joined by extendsFrom arrows

A few things in the output are worth reading closely:

  • implementation extends api, so what is exposed to consumers is also available to this project, which is why one declaration on api is enough. compileOnly extends compileOnlyApi for the same reason.

  • compileClasspath extends compileOnly and implementation; runtimeClasspath extends implementation and runtimeOnly. There is no line from compileOnly to the runtime and none from runtimeOnly to compilation; that is all compileOnly and runtimeOnly mean.

  • apiElements extends api and compileOnlyApi; runtimeElements extends implementation and runtimeOnly. A consumer compiles against the first and runs against the second.

  • annotationProcessor is both declarable and resolvable, an exception that the user manual’s table does not show (it lists it as declarable only): the resolvable configurations report lists it, and dependencies --configuration annotationProcessor resolves it. It is resolved to the processor path of the compiler (see annotationProcessor and runtimeOnly).

  • archives and default are legacy consumable configurations that are kept for backward compatibility (default extends runtimeElements, as the output shows, and is described as "Configuration for default artifacts"; the archives configuration was deprecated in Gradle 9.1 and will be removed in Gradle 10). mainSourceElements and testResultsElementsForTest are consumable configurations for tools, described by outgoingVariants as the list of the source directories of the main source set and the binary results of the test suite; no dependencies are declared on them.

  • The test configurations repeat the pattern: testImplementation extends implementation, testRuntimeOnly extends runtimeOnly, and testCompileOnly extends compileOnlyApi (so test code sees the annotations that the main code sees, but not what the main code declared compileOnly).

What the plugins add is a matter of what is applied, not of the Gradle version. The java plugin has implementation (since Gradle 3.4), compileOnly, runtimeOnly and the test ones, and no api, which is why a library that must tell its consumers what is exposed applies java-library. The War plugin adds providedCompile and providedRuntime, which differ from compileOnly in that the dependencies are on the runtime classpath of the project and the tests but are left out of the WAR. Other plugins create their own: the Android Gradle plugin per build type and flavor (debugImplementation), Kotlin Multiplatform per source set (commonMainImplementation); see Project Structure, Gradle and Build Variants and Dependencies and the Library Ecosystem. The java-test-fixtures plugin (Gradle 5.6) adds testFixturesApi and testFixturesImplementation, and the JVM Test Suite plugin a set per suite (intTestImplementation and intTestRuntimeOnly for a suite called intTest); see Testing JVM Projects.

The history of these names is short enough to list:

Gradle Change

2.12

compileOnly (the equivalent of Maven’s provided, but not on the test classpath).

3.4

The java-library plugin, with api and implementation, and the first configurations that cannot be resolved (implementation and apiElements among them). compile and runtime are deprecated in favour of the new ones.

4.6

The annotationProcessor configurations.

5.6

The java-test-fixtures plugin.

6.7

compileOnlyApi.

7.0

compile, runtime, testCompile and testRuntime (and <sourceSet>Compile, <sourceSet>Runtime) are removed.

8.4

dependencyScope, resolvable and consumable (incubating); the core plugins are to migrate to them in Gradle 9.0.

9.0

Using a configuration in a way its role does not allow is an error.

api versus implementation

For a library, the most important decision about a dependency is whether the types of that dependency appear in the library’s own interface. If they do, the dependency is part of the API: the consumers need it to compile against the library, so it goes on api and travels to their compile classpath. If it is only used inside, it goes on implementation, and the consumers get it at run time but not to compile against. The user manual’s rule of thumb is to prefer implementation whenever possible, and to reach for api when a type of the dependency is in the library’s binary interface:

  • types used in a super class or an interface of a public class;

  • types in the parameters (including the generic parameters) of a public or protected method;

  • types of public fields;

  • public annotations.

A type used only in method bodies, in private members or in internal classes is not part of the API. The compiler cannot tell the difference; Gradle can only be told. Choosing implementation is also the safer error: if an internal type leaks, the consumers fail to compile at once and you notice. Choosing api by habit makes every consumer’s compile classpath larger and every change of the dependency a reason to recompile them, and it lets consumers write code against a library they never declared, which breaks when the library stops exposing it. (The user manual lists faster compilation and less recompilation as the benefits; that is not measured here.)

The three builds

The model library has no dependencies of its own to compile, only the annotations (see below). The engine library exposes the model in its API, because a method of TideEngine takes a Station and returns a Height, and uses Commons CSV only inside StationReader. The command line application is a consumer. These are the three build scripts as they ran, with the printClasspaths and printConfigurations tasks that the tables on this page come from:

Kotlin DSL: tidepool-model/build.gradle.kts
plugins {
    `java-library`
}

dependencies {
    compileOnlyApi(libs.jetbrains.annotations)

    testImplementation(libs.junit.jupiter)
    testRuntimeOnly(libs.junit.platform.launcher)
}

tasks.withType<JavaCompile>().configureEach {
    options.release = 21
}

tasks.test {
    useJUnitPlatform()
}
Kotlin DSL: tidepool-engine/build.gradle.kts
plugins {
    `java-library`
}

dependencies {
    api(project(":tidepool-model"))
    implementation(libs.commons.csv) {
        because("reads the stations file")
    }
    compileOnly(libs.auto.service.annotations)
    annotationProcessor(libs.auto.service)

    testImplementation(libs.junit.jupiter)
    testRuntimeOnly(libs.junit.platform.launcher)
}

tasks.withType<JavaCompile>().configureEach {
    options.release = 21
}

tasks.test {
    useJUnitPlatform()
}

tasks.register("printClasspaths") {
    val classpaths: Map<String, FileCollection> = mapOf(
        "annotationProcessor" to configurations.annotationProcessor.get(),
        "compileClasspath" to configurations.compileClasspath.get(),
        "runtimeClasspath" to configurations.runtimeClasspath.get(),
    )
    val root = rootDir
    doLast {
        classpaths.forEach { (name, files) ->
            println("$name:")
            files.map { if (it.startsWith(root)) it.relativeTo(root).path else it.name }.sorted().forEach { println("  $it") }
        }
    }
}

tasks.register("printConfigurations") {
    val rows: Provider<List<String>> = provider {
        configurations.sortedBy { it.name }.map { c ->
            val roles = listOfNotNull(
                "declarable".takeIf { c.isCanBeDeclared },
                "resolvable".takeIf { c.isCanBeResolved },
                "consumable".takeIf { c.isCanBeConsumed },
            ).joinToString("+")
            "${c.name} [$roles] extends ${c.extendsFrom.map { it.name }.sorted()}"
        }
    }
    inputs.property("rows", rows)
    doLast {
        rows.get().forEach { println(it) }
    }
}
Kotlin DSL: tidepool-cli/build.gradle.kts
plugins {
    application
}

dependencies {
    implementation(project(":tidepool-engine"))
    implementation(libs.picocli)
    runtimeOnly(libs.slf4j.simple)
}

application {
    mainClass = "io.tidepool.cli.Main"
}

tasks.withType<JavaCompile>().configureEach {
    options.release = 21
}

tasks.register("printClasspaths") {
    val classpaths: Map<String, FileCollection> = mapOf(
        "annotationProcessor" to configurations.annotationProcessor.get(),
        "compileClasspath" to configurations.compileClasspath.get(),
        "runtimeClasspath" to configurations.runtimeClasspath.get(),
    )
    val root = rootDir
    doLast {
        classpaths.forEach { (name, files) ->
            println("$name:")
            files.map { if (it.startsWith(root)) it.relativeTo(root).path else it.name }.sorted().forEach { println("  $it") }
        }
    }
}
Groovy DSL: tidepool-model/build.gradle
plugins {
    id 'java-library'
}

dependencies {
    compileOnlyApi libs.jetbrains.annotations

    testImplementation libs.junit.jupiter
    testRuntimeOnly libs.junit.platform.launcher
}

tasks.withType(JavaCompile).configureEach {
    options.release = 21
}

tasks.named('test') {
    useJUnitPlatform()
}
Groovy DSL: tidepool-engine/build.gradle
plugins {
    id 'java-library'
}

dependencies {
    api project(':tidepool-model')
    implementation(libs.commons.csv) {
        because 'reads the stations file'
    }
    compileOnly libs.auto.service.annotations
    annotationProcessor libs.auto.service

    testImplementation libs.junit.jupiter
    testRuntimeOnly libs.junit.platform.launcher
}

tasks.withType(JavaCompile).configureEach {
    options.release = 21
}

tasks.named('test') {
    useJUnitPlatform()
}

tasks.register('printClasspaths') {
    Map<String, FileCollection> classpaths = [
        annotationProcessor: configurations.annotationProcessor,
        compileClasspath   : configurations.compileClasspath,
        runtimeClasspath   : configurations.runtimeClasspath,
    ]
    File root = rootDir
    doLast {
        classpaths.each { name, files ->
            println "${name}:"
            files.collect { it.toPath().startsWith(root.toPath()) ? root.toPath().relativize(it.toPath()).toString() : it.name }.sort().each { println "  ${it}" }
        }
    }
}

tasks.register('printConfigurations') {
    Provider<List<String>> rows = provider {
        configurations.sort { it.name }.collect { c ->
            def roles = []
            if (c.canBeDeclared) roles << 'declarable'
            if (c.canBeResolved) roles << 'resolvable'
            if (c.canBeConsumed) roles << 'consumable'
            "${c.name} [${roles.join('+')}] extends ${c.extendsFrom*.name.sort()}".toString()
        }
    }
    inputs.property('rows', rows)
    doLast {
        rows.get().each { println it }
    }
}
Groovy DSL: tidepool-cli/build.gradle
plugins {
    id 'application'
}

dependencies {
    implementation project(':tidepool-engine')
    implementation libs.picocli
    runtimeOnly libs.slf4j.simple
}

application {
    mainClass = 'io.tidepool.cli.Main'
}

tasks.withType(JavaCompile).configureEach {
    options.release = 21
}

tasks.register('printClasspaths') {
    Map<String, FileCollection> classpaths = [
        annotationProcessor: configurations.annotationProcessor,
        compileClasspath   : configurations.compileClasspath,
        runtimeClasspath   : configurations.runtimeClasspath,
    ]
    File root = rootDir
    doLast {
        classpaths.each { name, files ->
            println "${name}:"
            files.collect { it.toPath().startsWith(root.toPath()) ? root.toPath().relativize(it.toPath()).toString() : it.name }.sort().each { println "  ${it}" }
        }
    }
}

./gradlew --configuration-cache clean build ran both builds, with the same result (seventeen tasks, the tests of the model and the engine included); the second run was a cache hit:

BUILD SUCCESSFUL in 1s
17 actionable tasks: 17 executed
Configuration cache entry stored.
Reusing configuration cache.
BUILD SUCCESSFUL in 370ms
13 actionable tasks: 13 up-to-date
Configuration cache entry reused.

The tree of declarable configurations of the engine shows exactly what was written, and nothing else (the project dependency is project tidepool-model because the configuration has not been resolved):

api - API dependencies for the 'main' feature. (n)
\--- project tidepool-model (n)
implementation - Implementation dependencies for the 'main' feature. (n)
\--- org.apache.commons:commons-csv:1.14.1 (n)

These files are this page’s own setup for the examples, not an example taken from the manual.

What the consumer sees

The command line application is the consumer. Its compile classpath contains the engine, because it declared it, and the model, because the engine put it on api; it does not contain Commons CSV, which the engine put on implementation. Its runtime classpath contains all of them, plus slf4j-simple that the application declared on runtimeOnly:

compileClasspath - Compile classpath for source set 'main'.
+--- project ':tidepool-engine'
|    \--- project ':tidepool-model'
|         \--- org.jetbrains:annotations:26.1.0
\--- info.picocli:picocli:4.7.7
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
\--- org.slf4j:slf4j-simple:2.0.17
     \--- org.slf4j:slf4j-api:2.0.17

The compile classpath tree also contains org.jetbrains:annotations, under the model, which is not a mistake: the model declared it with compileOnlyApi, and a consumer compiles against the model’s API variant, which includes it. It is not in the runtime classpath tree. That is the one thing compileOnlyApi is for: annotations (or a servlet API, or any dependency that the container provides) that the signatures of the library mention, and that must be visible to the compiler of the consumers but never shipped.

The leak, as a compile error

To see the difference from the other side, api(project(":tidepool-model")) was changed to implementation(project(":tidepool-model")) in a scratch copy of the engine build. The command line application, which imports io.tidepool.model.Station although it never declared the model, no longer compiles:

> Task :tidepool-cli:compileJava FAILED
<work>/tidepool-cli/src/main/java/io/tidepool/cli/Main.java:5: error: package io.tidepool.model does not exist
import io.tidepool.model.Station;
                        ^
<work>/tidepool-cli/src/main/java/io/tidepool/cli/Main.java:24: error: cannot find symbol
            for (Station s : StationReader.read(reader)) {
                 ^
  symbol:   class Station
  location: class Main
2 errors

Its compile classpath now has no model either:

compileClasspath - Compile classpath for source set 'main'.
+--- project ':tidepool-engine'
\--- info.picocli:picocli:4.7.7

The Groovy DSL build failed with the same two errors and printed the same tree. The application used a class that it only got by accident of the engine’s declaration, and there are two repairs. The application can declare implementation(project(":tidepool-model")) itself, which is right when its own code uses the model as a first-class dependency; or the engine can declare the model with api, which is right when its own public methods take and return the model’s types (as TideEngine.predict(Station, Instant) does, so api is what the example has).

Where each declaration ends up

The printClasspaths task of the engine and of the application shows which jars and directories were actually put on which path. This is the engine, a library with a project dependency on the model:

annotationProcessor:
  auto-common-1.2.1.jar
  auto-service-1.1.1.jar
  auto-service-annotations-1.1.1.jar
  checker-qual-3.33.0.jar
  error_prone_annotations-2.18.0.jar
  failureaccess-1.0.1.jar
  guava-32.0.1-jre.jar
  j2objc-annotations-2.8.jar
  jsr305-3.0.2.jar
  listenablefuture-9999.0-empty-to-avoid-conflict-with-guava.jar
compileClasspath:
  annotations-26.1.0.jar
  auto-service-annotations-1.1.1.jar
  commons-codec-1.19.0.jar
  commons-csv-1.14.1.jar
  commons-io-2.20.0.jar
  tidepool-model/build/classes/java/main
runtimeClasspath:
  commons-codec-1.19.0.jar
  commons-csv-1.14.1.jar
  commons-io-2.20.0.jar
  tidepool-model/build/libs/tidepool-model-1.0.0.jar

And the consumer, the command line application:

annotationProcessor:
compileClasspath:
  annotations-26.1.0.jar
  picocli-4.7.7.jar
  tidepool-engine/build/classes/java/main
  tidepool-model/build/classes/java/main
runtimeClasspath:
  commons-codec-1.19.0.jar
  commons-csv-1.14.1.jar
  commons-io-2.20.0.jar
  picocli-4.7.7.jar
  slf4j-api-2.0.17.jar
  slf4j-simple-2.0.17.jar
  tidepool-engine/build/libs/tidepool-engine-1.0.0.jar
  tidepool-model/build/libs/tidepool-model-1.0.0.jar

Both outputs are the same for the Groovy DSL. Reading them with the declarations in front of you:

  • The model appears in the engine’s compile classpath as tidepool-model/build/classes/java/main, a directory, and in its runtime classpath as a jar. For a project dependency, Gradle gives the compiler the classes variant (a directory of class files) instead of building the jar, which is cheaper; the runtime needs the jar. The directory is a secondary variant of the model’s apiElements, and the jar comes from its runtimeElements (see Viewing and debugging dependencies).

  • The application’s compile classpath has picocli (declared implementation), the engine and the model (a project dependency and its api), and the annotations of the model’s compileOnlyApi. It has none of commons-csv, slf4j-simple or the processor.

  • The application’s runtime classpath has the jars of the engine and of the model, commons-csv and its two dependencies from the engine’s implementation, and the slf4j-simple and slf4j-api that it declared on runtimeOnly. It has no annotations, no auto-service-annotations.

  • The engine’s compileClasspath has auto-service-annotations (its compileOnly), which its runtimeClasspath does not.

compileOnly, annotationProcessor and runtimeOnly

compileOnly is for what the compiler needs and the running program must not have: annotations that are not used at run time, a servlet API that the container provides, or a dependency that is shaded into the jar. It is what Maven calls provided, with one difference that the Gradle 2.12 release notes state: compileOnly dependencies are not on the test classpath, which a Maven provided dependency is. A test that needs the servlet API declares it again on testImplementation, or on testCompileOnly if the tests only compile against it. The engine proves it: its testCompileClasspath has the model (a project dependency, with the annotations the model exports through compileOnlyApi), Commons CSV and JUnit, and no auto-service-annotations:

testCompileClasspath - Compile classpath for source set 'test'.
+--- project ':tidepool-model'
|    \--- org.jetbrains:annotations:26.1.0
+--- org.apache.commons:commons-csv:1.14.1
|    +--- commons-io:commons-io:2.20.0
|    \--- commons-codec:commons-codec:1.19.0
\--- org.junit.jupiter:junit-jupiter:6.1.3
     +--- org.junit:junit-bom:6.1.3
...

annotationProcessor is a separate bucket, since Gradle 4.6, because what the compiler compiles against and what it runs as a processor are different things. Gradle 4.6 deprecated putting processors on the compile classpath and Gradle 5.0 stopped applying them, so a processor declared on the wrong configuration is not an error: it is silently not run. That was tried here, in a scratch copy of the engine with the AutoService processor on compileOnly only. The build succeeded without a warning on JDK 26, and build/classes/java/main has no META-INF directory at all. The engine uses Google’s AutoService, which generates the META-INF/services registration of a class from an annotation. The annotations are compileOnly, because they are not needed at run time, and the processor is annotationProcessor:

Kotlin DSL
dependencies {
    compileOnly(libs.auto.service.annotations)
    annotationProcessor(libs.auto.service)
}
Groovy DSL
dependencies {
    compileOnly libs.auto.service.annotations
    annotationProcessor libs.auto.service
}

The class that uses them is io.tidepool.engine.Warmup (a class with @AutoService(Runnable.class)). After the build, the processor has written build/classes/java/main/META-INF/services/java.lang.Runnable with the line io.tidepool.engine.Warmup. The tree of the configuration is a processor with its own dependencies (Guava, among others), which are not in the compile or the runtime classpath, as the printClasspaths output above shows in its first section:

annotationProcessor - Annotation processors and their dependencies for source set 'main'.
\--- com.google.auto.service:auto-service:1.1.1
     +--- com.google.auto.service:auto-service-annotations:1.1.1
     +--- com.google.auto:auto-common:1.2.1
     |    \--- com.google.guava:guava:31.0.1-jre -> 32.0.1-jre
          ...

(The tree is cut after the fifth line, for length; the arrow on its last line is explained in the section "Viewing and debugging dependencies".) The test source set has its own testAnnotationProcessor, empty here.

runtimeOnly is for the opposite case: something the running program needs and the code is not written against, such as a logging backend or a JDBC driver. The command line application declares slf4j-simple on it, and the tree and the classpaths above show that it is in the runtime classpath and not in the compile one. Maven calls this scope runtime.

Test dependencies

Tests have their own copy of the three Java buckets, each extending the main one (see the table above), so a test sees everything the main code sees and what it adds. Dependencies on a test framework go on testImplementation (the framework’s API, which the test classes compile against) and testRuntimeOnly (the engine that runs them and is not compiled against). The model and the engine declare JUnit Jupiter and its launcher this way, and enable the platform on the task:

Kotlin DSL
dependencies {
    testImplementation(libs.junit.jupiter)
    testRuntimeOnly(libs.junit.platform.launcher)
}

tasks.test {
    useJUnitPlatform()
}
Groovy DSL
dependencies {
    testImplementation libs.junit.jupiter
    testRuntimeOnly libs.junit.platform.launcher
}

tasks.named('test') {
    useJUnitPlatform()
}

The launcher is a classic cause of a failing build. Gradle used to load the test framework’s own dependencies from its distribution when a build did not declare them; that was deprecated in Gradle 8.2 and removed in Gradle 9.0, and a build that relied on it stops with this message. It was produced by removing the testRuntimeOnly line from the model (without the configuration cache; the executor number is Gradle’s):

* What went wrong:
Execution failed for task ':tidepool-model:test' (registered by plugin 'org.gradle.jvm-test-suite').
> Test process encountered an unexpected problem.
   > Could not start Gradle Test Executor 9.
      > Failed to load JUnit Platform.  Please ensure that all JUnit Platform dependencies are available on the test's runtime classpath, including the JUnit Platform launcher.

The Groovy DSL printed the same. The default test task belongs to the default test suite, whose configurations are testImplementation and its siblings; further suites get their own, named from the suite. Builds that use the test suite DSL (useJUnitJupiter(version)) have the framework libraries applied to the suite for them, which is the migration the upgrade guide recommends; the whole subject is on Testing JVM Projects.

Your own configurations

The Java plugins' configurations cover compiling, running and testing. A build needs its own whenever a set of dependencies has a purpose of its own: the classpath of a code generator, a documentation tool, a set of agents, a smoke test, or the data that one project hands to another. The user manual names three uses: separating API from implementation (which the plugins already do), a resolvable configuration for a classpath, and a consumable configuration to expose something. The tidepool root project, which has no code, has one of each role:

Kotlin DSL: build.gradle.kts
plugins {
    base
}

// A bucket for the dependencies of the build's own tooling
val tideTools = configurations.dependencyScope("tideTools")

// The classpath those dependencies resolve to
val tideToolsClasspath = configurations.resolvable("tideToolsClasspath") {
    extendsFrom(tideTools.get())
}

dependencies {
    tideTools("info.picocli:picocli-codegen:4.7.7")
}

// What the root project offers to other projects: the station data
val stationsZip = tasks.register<Zip>("stationsZip") {
    from("data")
    destinationDirectory = layout.buildDirectory.dir("distributions")
    archiveBaseName = "stations"
}

val dataKind = Attribute.of("io.tidepool.data", String::class.java)

val stationData = configurations.consumable("stationData") {
    attributes.attribute(dataKind, "stations")
    outgoing.artifact(stationsZip)
}

tasks.register("listTideTools") {
    val files: FileCollection = tideToolsClasspath.get()
    doLast {
        files.map { it.name }.sorted().forEach { println(it) }
    }
}
Groovy DSL: build.gradle
plugins {
    id 'base'
}

// A bucket for the dependencies of the build's own tooling
def toolsBucket = configurations.dependencyScope('tideTools')

// The classpath those dependencies resolve to
def tideToolsClasspath = configurations.resolvable('tideToolsClasspath') {
    extendsFrom(toolsBucket.get())
}

dependencies {
    tideTools 'info.picocli:picocli-codegen:4.7.7'
}

// What the root project offers to other projects: the station data
def stationsZip = tasks.register('stationsZip', Zip) {
    from 'data'
    destinationDirectory = layout.buildDirectory.dir('distributions')
    archiveBaseName = 'stations'
}

def dataKind = Attribute.of('io.tidepool.data', String)

def stationData = configurations.consumable('stationData') {
    attributes.attribute(dataKind, 'stations')
    outgoing.artifact(stationsZip)
}

tasks.register('listTideTools') {
    FileCollection files = tideToolsClasspath.get()
    doLast {
        files.collect { it.name }.sort().each { println it }
    }
}
  • configurations.dependencyScope("tideTools") creates the bucket. In the Kotlin DSL the returned provider can be used as a function in dependencies (tideTools("...")); in the Groovy DSL the variable must not have the name of the configuration (it was called toolsBucket), or the call resolves to the variable and fails with Could not find method call().

  • configurations.resolvable("tideToolsClasspath") { extendsFrom(...) } creates the consumer. It has no dependencies of its own; it extends the bucket. Its files are the resolved classpath.

  • configurations.consumable("stationData") creates the producer, and outgoing.artifact(stationsZip) says what it offers. A consumable configuration needs attributes to take part in variant-aware resolution (the report marks one without them (n)); the attribute io.tidepool.data = stations is a custom one, and how attributes are matched is on Variant-Aware Resolution & Module Metadata. The multi-project page has an example of a project that consumes a configuration of another.

The task listTideTools resolves the classpath at execution time:

picocli-4.7.7.jar
picocli-codegen-4.7.7.jar

and the dependency report of the resolvable configuration, which is the same view as for any other, shows picocli-codegen and its picocli:

tideToolsClasspath
\--- info.picocli:picocli-codegen:4.7.7
     \--- info.picocli:picocli:4.7.7

The consumable one shows up in outgoingVariants with its capabilities, its attribute and its artifact (both DSLs printed the same; outgoingVariants is described in the next section):

--------------------------------------------------
Variant stationData
--------------------------------------------------

Capabilities
    - io.tidepool:tidepool:1.0.0 (default capability)
Attributes
    - io.tidepool.data = stations
Artifacts
    - build/distributions/stations-1.0.0.zip (artifactType = zip)

What the role locks out

Because the roles are fixed at creation, the mistakes that the older, mutable configurations allowed are errors. Each of these was run in a scratch copy of the build. Declaring a dependency on a resolvable configuration:

* What went wrong:
Dependencies can not be declared against the `tideToolsClasspath` configuration.

Resolving a bucket, in a task action (without the configuration cache, to see the message by itself):

* What went wrong:
Execution failed for task ':resolveBucket' (registered in build file 'build.gradle.kts').
> Resolving dependency configuration 'tideTools' is not allowed as it is defined as 'canBeResolved=false'.
  Instead, a resolvable ('canBeResolved=true') dependency configuration that extends 'tideTools' should be resolved.

The Groovy DSL printed the same messages (with the line A problem occurred evaluating root project 'tidepool'. before the first, since it fails while the script runs). Both messages tell you the fix: the second even names it, a resolvable configuration that extends the bucket. In Gradle 8.x, calling methods on a configuration whose role does not suit them, such as files or resolve on a bucket, was deprecated and warned at run time; since Gradle 9.0 it is an error (see "Gradle 8.14 and Gradle 9").

Resolving at execution time, and the configuration cache

The same task as a user would write it first, with the build script object used in the action, is the example of the cache’s complaint:

Kotlin DSL (fails with the configuration cache)
tasks.register("resolveBucket") {
    doLast {
        println(configurations.getByName("tideTools").files)
    }
}
Groovy DSL (fails with the configuration cache)
tasks.register('resolveBucket') {
    doLast {
        println configurations.getByName('tideTools').files
    }
}

On top of the error above, the configuration cache stops the build, because the action refers to a script object. The Kotlin DSL message and the Groovy DSL message differ:

Kotlin DSL
1 problem was found storing the configuration cache.
- Task `:resolveBucket` of type `org.gradle.api.DefaultTask`: cannot serialize Gradle script object references as these are not supported with the configuration cache.
  See https://docs.gradle.org/9.8.1/userguide/configuration_cache_requirements.html#config_cache:requirements:disallowed_types
Groovy DSL
1 problem was found storing the configuration cache.
- Task `:resolveBucket` of type `org.gradle.api.DefaultTask`: invocation of 'configurations' references a Gradle script object from a Groovy closure at execution time, which is unsupported with the configuration cache.
  See https://docs.gradle.org/9.8.1/userguide/configuration_cache_requirements.html#config_cache:requirements:gradle_model_types

The fix is the one printClasspaths and listTideTools use: take the FileCollection (a configuration is one) while the task is configured, keep it in a local value, and read it in the action. The resolution then happens at execution time, as a task input, and the cache can store the task. Gradle 9.7 made ResolutionResult fully compatible with the configuration cache, so that tasks can use the higher-level dependency resolution APIs; see Configuration Cache & Isolated Projects.

Inheritance, flags and laziness

extendsFrom makes a configuration inherit the dependencies, the dependency constraints, the exclude rules, the artifacts and the capabilities of another in the same project. It does not inherit the attributes or the role. The user manual advises against extending a consumable or resolvable configuration with one that is not. A configuration that is both resolvable and consumable can select itself when it is resolved; the 8.x upgrade guide deprecated that and announced an error from Gradle 9.0, and the role factory methods avoid it by construction. A typical use of extendsFrom is a smoke test configuration that extends testImplementation to reuse the test framework.

The older way to create the same configurations is configurations.create("name") and setting isCanBeResolved, isCanBeConsumed and isCanBeDeclared (canBeResolved = false in the Groovy DSL). It still works. The role factory methods are the replacement, and the user manual’s advice for build logic that touches configurations is to keep it lazy: configurations.all { } and getByName realize every configuration at once, while configureEach { } and named(...).configure { } only configure what is used. Since Gradle 9.2 the consumable configurations of the bundled plugins are also initialized lazily, so a configure action on apiElements no longer runs at configuration time unless something realizes it.

Viewing and debugging dependencies

Four reports answer most questions. All of them are tasks of every project (./gradlew :tidepool-cli:dependencies), and with the Java plugins, dependencyInsight has a default configuration of compileClasspath.

dependencies prints the tree of every configuration of the project, or of one with --configuration (the name can be abbreviated by its camel case, tCC for testCompileClasspath, as long as it is unambiguous). The trees above are its output. Its annotations are:

Mark Meaning

(*)

The subtree was shown already and is not repeated.

(c)

A dependency constraint, not a dependency: look for the matching dependency elsewhere in the tree.

(n)

A dependency, or a configuration, that cannot be resolved (a declarable configuration, as in the trees above).

->

The requested version on the left was replaced by the selected one on the right, such as guava:31.0.1-jre -> 32.0.1-jre in the processor tree.

The buildEnvironment task renders the same tree for the classpath of the build scripts themselves (the plugins and what they bring). The tree of the annotation processor above has an instance of the arrow: auto-common asked for guava:31.0.1-jre, and auto-service brought guava:32.0.1-jre; the conflict rules that decide this are on Dependency Resolution, Conflicts & Capabilities.

dependencyInsight explains one dependency in one configuration: why it is there, which version was selected and which paths lead to it. It takes --dependency (a part of group:name is enough), --configuration (default compileClasspath for Java projects), --single-path and --all-variants. The because of the engine’s Commons CSV declaration is a selection reason:

org.apache.commons:commons-csv:1.14.1
  Variant runtime:
    | Attribute Name                 | Provided     | Requested    |
    |--------------------------------|--------------|--------------|
    | org.gradle.status              | release      |              |
    | org.gradle.category            | library      | library      |
    | org.gradle.libraryelements     | jar          | jar          |
    | org.gradle.usage               | java-runtime | java-runtime |
    | org.gradle.dependency.bundling |              | external     |
    | org.gradle.jvm.environment     |              | standard-jvm |
    | org.gradle.jvm.version         |              | 21           |
   Selection reasons:
      - Was requested: reads the stations file

org.apache.commons:commons-csv:1.14.1
\--- project ':tidepool-engine'
     \--- runtimeClasspath

The table lists the attributes the variant provided and those the consumer requested, and how they matched; the bottom tree says the dependency is there because the engine’s implementation put it on the application’s runtimeClasspath. The Groovy DSL printed the same. The possible reasons (By conflict resolution, By constraint, Selected by rule, Forced, Was requested : <because>) are listed in the user manual.

outgoingVariants lists the variants a project offers (its consumable configurations and their secondary variants), with attributes and artifacts. For the engine, the API variant is:

--------------------------------------------------
Variant apiElements
--------------------------------------------------
API elements for the 'main' feature.

Capabilities
    - io.tidepool:tidepool-engine: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-engine-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.

The secondary variant classes is why the application’s compile classpath saw the model as a directory: the consumer asked for libraryelements=classes and got it. resolvableConfigurations is its counterpart for resolvable configurations, with the attributes each requests and the configurations it extends; for the engine it lists annotationProcessor, compileClasspath, runtimeClasspath and the test ones. Since Gradle 9.5 both reports hide variants and configurations that have no attributes unless --all is given (they are marked (n), "not selectable via attributes"); the custom stationData variant above has an attribute and is shown. The reports and the attributes are the subject of Variant-Aware Resolution & Module Metadata.

A Build Scan (--scan) gives a searchable version of the same graph, with the reasons; publishing one needs accepting the terms of use of a service and was not run here. The reports are enough for nearly every question, and their output is text that can be diffed in CI.

Dependencies in the published metadata

What the configurations were declared on is, for a published library, what its consumers see. The model and the engine were published with maven-publish to a file:// repository inside the scratch build (publishAllPublicationsToTidepoolRepository; the full publishing page is Publishing), which wrote a POM and a Gradle Module Metadata file (.module) for each. The engine’s POM has the scopes:

  <dependencies>
    <dependency>
      <groupId>io.tidepool</groupId>
      <artifactId>tidepool-model</artifactId>
      <version>1.0.0</version>
      <scope>compile</scope>
    </dependency>
    <dependency>
      <groupId>org.apache.commons</groupId>
      <artifactId>commons-csv</artifactId>
      <version>1.14.1</version>
      <scope>runtime</scope>
    </dependency>
  </dependencies>

api became compile, implementation and runtimeOnly both became runtime, and compileOnly and the test configurations were not written at all. The model’s POM has one dependency, which came from compileOnlyApi:

  <dependencies>
    <dependency>
      <groupId>org.jetbrains</groupId>
      <artifactId>annotations</artifactId>
      <version>26.1.0</version>
      <scope>compile</scope>
    </dependency>
  </dependencies>

A POM has no way to say "visible to compilers of consumers, not at run time", so Gradle writes the compileOnlyApi dependency as compile, the closest scope, and a Maven consumer gets the annotations at run time as well. The .module file is richer and keeps the two apart. These are the parts that matter in the engine’s file, cut with ... lines:

      "name": "apiElements",
      ...
      "dependencies": [
        {
          "group": "io.tidepool",
          "module": "tidepool-model",
          "version": {
            "requires": "1.0.0"
          }
        }
      ],
      ...
      "name": "runtimeElements",
      ...
      "dependencies": [
        {
          "group": "org.apache.commons",
          "module": "commons-csv",
          "version": {
            "requires": "1.14.1"
          }
        },
        {
          "group": "io.tidepool",
          "module": "tidepool-model",
          "version": {
            "requires": "1.0.0"
          }
        }
      ],

and in the model’s, where the API variant has the annotations and the runtime one has no dependencies at all:

      "name": "apiElements",
      ...
      "dependencies": [
        {
          "group": "org.jetbrains",
          "module": "annotations",
          "version": {
            "requires": "26.1.0"
          }
        }
      ],
      ...
      "name": "runtimeElements",
      ...
      "files": [
      ...

The api/implementation distinction therefore survives for Gradle consumers (which read the .module file) and is flattened to compile and runtime for Maven ones. A Maven project that depends on the engine from that repository, resolved with Maven 3.10.0 and the tree goal of maven-dependency-plugin 3.8.1 (the build banner and footer cut):

[INFO] io.tidepool.demo:maven-consumer:jar:1.0.0
[INFO] \- io.tidepool:tidepool-engine:jar:1.0.0:compile
[INFO]    +- io.tidepool:tidepool-model:jar:1.0.0:compile
[INFO]    |  \- org.jetbrains:annotations:jar:26.1.0:compile
[INFO]    \- org.apache.commons:commons-csv:jar:1.14.1:runtime
[INFO]       +- commons-io:commons-io:jar:2.20.0:runtime
[INFO]       \- commons-codec:commons-codec:jar:1.19.0:runtime

The model is compile (the engine’s api), Commons CSV is runtime (its implementation), and the annotations are compile, as the POM said. The Groovy DSL build was published as well and wrote a POM with the same dependencies. How the Gradle model compares with Maven’s on this point is on Dependency Management Fundamentals, and how metadata files map to each other (and how to switch Gradle Module Metadata off) on Variant-Aware Resolution & Module Metadata.

The mapping, in the direction of migration, is the one the user manual gives for builds that come from Maven:

Maven scope Gradle configuration Notes

compile

implementation, or api if the dependency is part of the library’s API

Maven has no difference between the two, which is why published POMs of Maven libraries put everything on the compile classpath of consumers.

runtime

runtimeOnly

provided

compileOnly (or compileOnlyApi)

Not on the test classpath. The War plugin’s providedCompile and providedRuntime keep the dependency out of the WAR but on the runtime classpath.

test

testImplementation, testRuntimeOnly, testCompileOnly

Gradle distinguishes compiling the tests from running them.

import (in dependencyManagement)

platform(...)

See Platforms, Constraints & Rich Versions.

system

a file dependency

Discouraged in both tools.

Mistakes and the messages they produce

Most of them are in the sections above with their messages. Two more have not been shown yet. The first is what a build from the Gradle 1.x or 2.x era meets when it is upgraded: declaring on compile, a configuration removed in Gradle 7.0:

Kotlin DSL
* What went wrong:
Script compilation error:

  Line 8:     compile("info.picocli:picocli:4.7.7")
              ^ Unresolved reference 'compile'.

1 error
Groovy DSL
* What went wrong:
A problem occurred evaluating project ':tidepool-cli'.
> Could not find method compile() for arguments [info.picocli:picocli:4.7.7] on object of type org.gradle.api.internal.artifacts.dsl.dependencies.DefaultDependencyHandler.

The Kotlin DSL fails when it compiles the script (the accessor compile no longer exists); the Groovy DSL fails when it runs it (the method does not exist). The same applies to runtime, testCompile and testRuntime. The replacements are implementation or api, runtimeOnly, testImplementation and testRuntimeOnly. A custom configuration that used extendsFrom runtime in the Groovy DSL silently created a new, empty configuration named runtime in Gradle 7, which is a worse symptom (the user manual warns of it).

A version that does not exist, which was run without the configuration cache because with it the build stopped on a serialization error that wraps the same message:

* What went wrong:
Execution failed for task ':tidepool-cli:compileJava' (registered by plugin class 'org.gradle.api.plugins.JavaBasePlugin').
> Could not resolve all files for configuration ':tidepool-cli:compileClasspath'.
   > Could not find info.picocli:picocli:4.7.99.
     Searched in the following locations:
       - https://repo.maven.apache.org/maven2/info/picocli/picocli/4.7.99/picocli-4.7.99.pom
     Required by:
         project ':tidepool-cli'

The Groovy DSL printed the same. The message says where Gradle looked (the repository URL from the settings), and which declaration required it; a typo in a version, a repository that does not have the module, or a repository content filter that excludes it all end here.

Symptom Cause Fix

package ... does not exist in a consumer, though the library depends on it

The library declared the dependency on implementation; it is not on the consumer’s compile classpath.

Declare it in the consumer, or put it on api in the library if the library’s API exposes its types.

Unresolved reference 'compile' (Kotlin), Could not find method compile() (Groovy)

A configuration removed in Gradle 7.0.

implementation, api, runtimeOnly, testImplementation, testRuntimeOnly.

Failed to load JUnit Platform in test

The launcher is not on the test runtime classpath.

testRuntimeOnly("org.junit.platform:junit-platform-launcher").

Dependencies can not be declared against the ... configuration

The configuration is resolvable or consumable.

Declare on a dependencyScope bucket and extend it.

Resolving dependency configuration '...' is not allowed as it is defined as 'canBeResolved=false'

A bucket was asked for its files.

Resolve a resolvable configuration that extends it.

cannot serialize Gradle script object references (cache)

The task action uses configurations, project or another script object.

Capture the file collection when configuring the task.

Could not find group:name:version

The version is wrong, or the repository does not have it.

Check the repository, the version and any content filter.

A processor does not run

It is on compileOnly or implementation instead of annotationProcessor.

annotationProcessor, and compileOnly for its annotations.

A class is missing at run time though it compiled

compileOnly (or an @jar notation) left it out of the runtime classpath.

Move it to implementation or runtimeOnly, or drop @jar.

Gradle 8.14 and Gradle 9

Gradle 8.14 was not available here, so what follows comes from the official upgrade guides and release notes and was not run here.

  • The roles. The role factory methods exist in 8.14 (they were added in 8.4, incubating). In the 8.x line a configuration used in a role it does not suit (resolving a bucket, for instance) produced a deprecation warning at run time; since 9.0 it is the error shown above. The 8.4 release notes announced that the core plugins would migrate to role-locked configurations in Gradle 9.0.

  • The multi-string notation. It is not deprecated in 8.14, and works without a warning; the deprecation is from 9.1 and the removal is announced for Gradle 10.

  • Project as a notation. It is not deprecated in 8.14; it is from 9.6 (the Gradle 10 build will fail).

  • The module coordinates of the current project. Deprecated in 9.3, and in Gradle 10 they will resolve against the repositories.

  • The reports. The outgoingVariants and resolvableConfigurations reports of 8.14 list every variant and configuration, with or without attributes; Gradle 9.5 hides the attribute-less ones unless --all is given.

  • Gradle 9 itself. It requires a JVM of version 17 or later to run. The user manual’s Groovy and Kotlin DSL examples are the same for both lines.

Announced for Gradle 10 and not released: the errors for the multi-string notation and for Project as a notation, the change in resolving one’s own module coordinates, and the configuration cache on by default (which rejects the pattern shown in "Resolving at execution time, and the configuration cache").

Where the books are outdated

Gradle in Action (Muschko, Manning 2014) was written for Gradle 1.7, and its chapters 5 and 6 are about dependencies. The model it teaches (configurations, module, file and project dependencies, exclusions, repositories) is still right. What changed around it:

Book statement or practice Current status

compile, runtime, testCompile and testRuntime

Removed in Gradle 7.0. Use implementation, api, compileOnly, runtimeOnly, testImplementation and testRuntimeOnly (the error is shown above).

compile project(':x') in a multi-project build

implementation project(':x') or api project(':x'), and the choice decides what consumers can compile against.

providedCompile for the container’s servlet API in a WAR

Still there with the War plugin; compileOnly is the general mechanism.

Versions repeated or kept in ext properties

Version catalogs (stable in 7.4).

The Groovy DSL only

The Kotlin DSL is the default of gradle init since 8.2; both are shown on this page.

Dynamic versions, repositories and the dependency cache, which the book also covers, are on Platforms, Constraints & Rich Versions, Repositories & Content Filtering and Dependency Locking, Verification & Caching.

The same in Maven and in Ant

Question Gradle Maven Ant with Ivy

Where is a dependency declared?

On a configuration: implementation("g:a:v").

In <dependencies>, with a <scope>.

In <dependencies> of ivy.xml, with conf="compile->default".

Which classpath does it join?

The resolvable configurations that extend the bucket.

The scope decides: compile, runtime, test classpaths.

The configurations you resolve (<ivy:resolve conf=...>, <ivy:cachepath conf=...>).

Does it reach my consumers?

api: compile and runtime. implementation, runtimeOnly: runtime only.

compile and runtime scopes are transitive; provided and test are not.

The -> mapping decides which of the dependency’s configurations are offered.

Is the compile-time interface separate from the runtime one?

Yes: apiElements and runtimeElements.

No: one POM, scopes flattened.

No built-in distinction; only the convention of compile and runtime configurations.

Where is a custom group of dependencies written?

A configuration of your own.

A plugin’s own <dependencies>; there is no named classpath of your own.

A configuration in ivy.xml.

Where to go next