Gradle Dependency Resolution, Conflicts & Capabilities

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; 8.14.6 is the last 8.x release). Every example was run through the Gradle wrapper on JDK 26, on macOS, in a scratch copy of the tidepool build: once with Kotlin DSL build files and once with Groovy DSL build files. The daemon and the compiler used the same JDK 26 (toolchains are the subject of Toolchains). Unless a line says otherwise the console output was identical in both DSLs, so each output is shown once. The builds ran with the configuration cache enabled (org.gradle.configuration-cache=true in gradle.properties) and all of them were compatible with it, with one exception that is named where it happens: a build that fails while resolving dependencies cannot be stored in the configuration cache, so the failure examples ran with --no-configuration-cache. Gradle 8.14 was not available here, so what is said about it comes from the official documentation and release notes and is labelled "not run here". Console output was trimmed only in these ways: the header and the legends of the dependencies task, the > Task progress lines of the report tasks (dependencies, dependencyInsight and the like; the three listings that compile or run code keep them) and the line with the link to the problems report were removed, the scratch directory was replaced by <work>, the attribute table of dependencyInsight was left out, and the generic hints at the end of a failure message (--stacktrace, --info, --scan) were cut; where a listing is shortened in any other way the text says so. The decimal comma in the program output comes from the locale of the machine.

The wrapper properties that a real project commits, with the official distribution URL (the scratch copies pointed the same file at a local copy of the same archive):

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

Resolving dependencies is the part of a Gradle build that takes the most thought and goes wrong in the most surprising ways: a library that nobody declared is on the classpath, a version is not the one that was written in the build file, or two libraries that do the same job are both there. This page explains what Gradle does, step by step, and then shows the three tools that Gradle gives you for the three kinds of surprise: the rules for version conflicts, the capability mechanism for two modules that provide the same functionality, and exclusions for a dependency that should not be there at all. Declaring dependencies and the configurations they live in is on Declaring Dependencies & Configurations; rich versions, platforms and constraints in depth are on Platforms, Constraints & Rich Versions; forcing versions, substitution and metadata rules are on Resolution Rules & Substitution; variants and attributes are on Variant-Aware Resolution & Module Metadata. The vocabulary that all three build tools share (coordinates, transitive dependencies, scopes) is on Dependency Management Fundamentals, which also runs one version conflict through Ant with Ivy, Maven and Gradle; this page goes deeper on the Gradle half of that comparison.

The resolution model

Dependency resolution in Gradle is a two-step process. In the first step, graph resolution, Gradle builds the dependency graph from the declared dependencies and the metadata of the modules they point to. In the second step, artifact resolution, it turns the nodes of that graph into files and fetches them. The two steps are separate on purpose: many questions (which version? why is this here?) can be answered from the graph alone, without downloading a single JAR.

flowchart LR decl["Declared dependencies and constraints of one resolvable configuration"] --> gres["Graph resolution: read metadata, resolve conflicts, select a variant per component"] meta[("Repositories: POM, Gradle Module Metadata, ivy.xml")] --> gres gres --> rgraph["Resolved graph: components, variants, edges and selection reasons"] rgraph --> reports["Tasks and reports that need only the graph: dependencies, dependencyInsight"] rgraph --> ares["Artifact resolution: pick an artifact set per variant, transform if needed, download"] ares --> files["Files: the compile or runtime classpath"]

Graph resolution

The documentation names the building blocks in the order Gradle meets them.

Term Meaning

Module

A published unit such as commons-io:commons-io. A dependency is a reference to a module.

Component

One version of a module, such as commons-io:commons-io:2.20.0.

Metadata

The description of a component in the repository: a Gradle Module Metadata file (.module), a Maven .pom or an Ivy ivy.xml.

Variant

A variation of a component for one use, such as the API a library exposes to compilation or the runtime elements it needs when executed. Each variant lists its own dependencies and its own files.

Attributes

Key-value pairs (org.gradle.usage = java-runtime) that describe a variant and that Gradle matches against the request of the configuration being resolved.

Node and edge

The graph is made of nodes, one per selected variant, and edges, one per dependency between two variants.

Each resolvable configuration resolves to its own graph. That is why compileClasspath and runtimeClasspath of the same project can contain different versions and even different modules: they ask for different attributes, and the metadata answers differently.

The loop that builds the graph works on one node at a time, starting from a queue that contains only the root node, which is the resolvable configuration itself. For every node it takes out of the queue, Gradle evaluates the node’s dependencies, decides which version each of them targets (this is where conflict resolution happens), downloads the metadata of all the components involved, selects a variant of each, and puts the selected variants back into the queue. The loop ends when the queue is empty. The documentation points out that this alternates between parallel metadata downloads and single-threaded logic.

Metadata comes from the repositories, and the rules for fetching it explain several surprises.

  • For a concrete version, Gradle checks the repositories in the order in which they are declared and stops at the first one that has metadata (a .module, a .pom or an ivy.xml) for it; a module that has only an artifact file ranks below one with metadata. All artifacts of the component are then taken from that same repository.

  • For a dynamic version (1.+, latest.release) Gradle must know which versions exist, so it asks all the repositories and does not stop at the first answer. For Maven repositories it reads maven-metadata.xml; for Ivy repositories it lists a directory.

  • A POM with a parent is resolved recursively, and everything, including misses, is stored in the dependency cache.

  • A repository that cannot be reached (unknown host, repeated connection failures, HTTP 5xx) is no longer checked for the rest of the build, after Gradle has retried with increasing wait times. Gradle does not move on to the next repository unless told to, so the resolution usually fails, which keeps builds reproducible: a build that silently skipped a faulty repository could give different results once it came back. The Repositories & Content Filtering page covers repository declaration and filtering, and Dependency Locking, Verification & Caching covers the cache.

  • mavenLocal() behaves like a cache of whatever Maven installed, so it can lack artifacts of a module whose metadata is there; the documentation warns about this in the same section.

Variant selection, the step that matches the attributes of the configuration against the variants in the metadata, has its own page: Variant-Aware Resolution & Module Metadata.

Two kinds of conflict are settled during graph resolution. A version conflict is two requests for different versions of the same module. A capability (or implementation) conflict is two different modules that declare the same capability. Both are the subject of this page.

Artifact resolution

Once the graph exists, Gradle maps each node to the files it needs. A variant can offer several artifact sets: the packaged JAR, the directory of compiled classes, the directory of resources, an archive of another type. For each node Gradle matches the requested attributes against the artifact sets it offers and picks one; if none matches, it tries to build a chain of artifact transforms that produces the requested form. How a project component hands out its classes directory instead of its JAR is the same mechanism.

The APIs that expose the result come in two groups. ResolutionResult gives the graph without downloading anything (components, variants, edges, selection reasons); the files of a configuration, an ArtifactCollection or an ArtifactView trigger artifact selection and the download. The older ResolvedConfiguration and LenientConfiguration APIs also trigger it and are in maintenance mode, so the documentation discourages them for new code.

Seeing the two phases

The tidepool build used in the rest of the page has three modules. The settings file declares the repository once, for all projects, and forbids project-level repositories:

Kotlin DSL
rootProject.name = "tidepool"

dependencyResolutionManagement {
    repositoriesMode = RepositoriesMode.FAIL_ON_PROJECT_REPOS
    repositories {
        mavenCentral()
    }
}

include("tidepool-model", "tidepool-engine", "tidepool-cli")
Groovy DSL
rootProject.name = 'tidepool'

dependencyResolutionManagement {
    repositoriesMode = RepositoriesMode.FAIL_ON_PROJECT_REPOS
    repositories {
        mavenCentral()
    }
}

include 'tidepool-model', 'tidepool-engine', 'tidepool-cli'

The model is a plain library, the engine exposes the model through api and uses Commons CSV internally, and the command-line tool uses the engine, picocli and, as a project often does for a library it also uses itself, commons-io 2.11.0:

Kotlin DSL
plugins {
    `java-library`
}
Groovy DSL
plugins {
    id 'java-library'
}
Kotlin DSL
plugins {
    `java-library`
}

dependencies {
    api(project(":tidepool-model"))
    implementation("org.apache.commons:commons-csv:1.14.1")
}
Groovy DSL
plugins {
    id 'java-library'
}

dependencies {
    api project(':tidepool-model')
    implementation 'org.apache.commons:commons-csv:1.14.1'
}
Kotlin DSL
plugins {
    application
}

dependencies {
    implementation(project(":tidepool-engine"))
    implementation("info.picocli:picocli:4.7.7")
    implementation("commons-io:commons-io:2.11.0")
}

application {
    mainClass = "io.tidepool.cli.Main"
}
Groovy DSL
plugins {
    id 'application'
}

dependencies {
    implementation project(':tidepool-engine')
    implementation 'info.picocli:picocli:4.7.7'
    implementation 'commons-io:commons-io:2.11.0'
}

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

The root gradle.properties holds the group and the version, which are the coordinates of the project components, and turns on the configuration cache:

org.gradle.configuration-cache=true
group=io.tidepool
version=1.0.0

The cli build file above adds one task for the next experiment. It receives the runtime classpath as an input file collection, which is what makes Gradle resolve the artifacts:

Kotlin DSL
plugins {
    application
}

dependencies {
    implementation(project(":tidepool-engine"))
    implementation("info.picocli:picocli:4.7.7")
    implementation("commons-io:commons-io:2.11.0")
}

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

// Asking for the files of a configuration is what triggers artifact resolution.
abstract class ShowFiles : DefaultTask() {
    @get:InputFiles
    abstract val files: ConfigurableFileCollection

    @TaskAction
    fun print() = files.forEach { println(it.name) }
}

tasks.register<ShowFiles>("showFiles") {
    files.from(configurations.runtimeClasspath)
}
Groovy DSL
plugins {
    id 'application'
}

dependencies {
    implementation project(':tidepool-engine')
    implementation 'info.picocli:picocli:4.7.7'
    implementation 'commons-io:commons-io:2.11.0'
}

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

// Asking for the files of a configuration is what triggers artifact resolution.
abstract class ShowFiles extends DefaultTask {
    @InputFiles
    abstract ConfigurableFileCollection getFiles()

    @TaskAction
    void print() {
        files.each { println it.name }
    }
}

tasks.register('showFiles', ShowFiles) {
    files.from(configurations.runtimeClasspath)
}

To watch the two phases, the experiment used a fresh Gradle user home so that its dependency cache started empty. First the graph only (dependencies resolves the graph and prints it; it does not ask for a file), then the files. After each step the files in the cache (caches/modules-2/files-2.1 under the user home) were counted by type.

./gradlew -q :tidepool-cli:dependencies --configuration runtimeClasspath
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
\--- commons-io:commons-io:2.11.0 -> 2.20.0

The cache after that command held only metadata:

   2 module
  11 pom

Then the files were requested:

./gradlew :tidepool-cli:showFiles
> Task :tidepool-model:processResources NO-SOURCE
> Task :tidepool-engine: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:jar UP-TO-DATE

> Task :tidepool-cli:showFiles
tidepool-engine-1.0.0.jar
picocli-4.7.7.jar
commons-csv-1.14.1.jar
commons-io-2.20.0.jar
tidepool-model-1.0.0.jar
commons-codec-1.19.0.jar

BUILD SUCCESSFUL in 951ms
5 actionable tasks: 1 executed, 4 up-to-date

and the cache was counted again:

   4 jar
   2 module
  11 pom

Resolving the graph had fetched thirteen metadata files (eleven .pom and two .module, among them the parent POMs that the libraries inherit from and the BOM that they import) and no JAR. Asking for the files fetched four JARs: Commons CSV, Commons IO 2.20.0, Commons Codec and picocli. The two project components (tidepool-model and tidepool-engine) are built, not downloaded, and Commons IO 2.11.0, the version written in the cli build file, never became a file: Gradle read its POM during the graph phase, because someone asked for it, and then discarded it in favour of 2.20.0. That is the whole story of this page in one listing, and the next sections explain why 2.20.0 won. The run was the same with the Groovy DSL build files.

Version conflicts

The highest version wins

When two paths of the graph request different versions of the same module, Gradle considers every requested version, wherever it appears in the graph, and by default selects the highest. The tidepool graph has such a conflict: tidepool-cli declares commons-io 2.11.0, while Commons CSV 1.14.1, reached through tidepool-engine, requires 2.20.0.

flowchart TD CLI["tidepool-cli"] --> ENG["tidepool-engine"] CLI -->|"declares 2.11.0"| IO11["commons-io 2.11.0 (requested, not selected)"] ENG --> CSV["commons-csv 1.14.1"] CSV -->|"requires 2.20.0"| IO20["commons-io 2.20.0 (selected)"] CSV --> COD["commons-codec 1.19.0"] CLI --> PIC["picocli 4.7.7"] IO11 -.->|"Gradle keeps the highest: 2.20.0 beats 2.11.0"| IO20 IO11 -.->|"Maven keeps the nearest: 2.11.0 beats 2.20.0"| MV["Maven's choice: commons-io 2.11.0"]

The dependencies task prints the graph with the resolution already applied, and dependencyInsight explains one module:

./gradlew -q :tidepool-cli:dependencies --configuration runtimeClasspath
./gradlew -q :tidepool-cli:dependencyInsight --dependency commons-io --configuration runtimeClasspath
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
\--- commons-io:commons-io:2.11.0 -> 2.20.0
commons-io:commons-io:2.20.0
  Variant runtime:
   Selection reasons:
      - By conflict resolution: between versions 2.20.0 and 2.11.0

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

commons-io:commons-io:2.11.0 -> 2.20.0
\--- runtimeClasspath

(The attribute table of the insight report, nine rows of org.gradle.* attributes with their provided and requested values, was left out.) These two reports are the instrument for everything that follows, so they are worth reading closely.

  • commons-io:commons-io:2.11.0 -> 2.20.0 means "requested on the left, selected on the right". The same module appears twice in the tree, once for each request, and the arrow is on the request that did not win.

  • The first block of dependencyInsight is the selected component, followed by its selection reasons. By conflict resolution: between versions 2.20.0 and 2.11.0 is the answer to "why this version?".

  • The following blocks are the requests, each shown with the chain of dependencies that led to it, up to the root configuration. commons-io:commons-io:2.20.0 comes from commons-csv, which comes from the project tidepool-engine; commons-io:commons-io:2.11.0 -> 2.20.0 comes straight from runtimeClasspath, that is, from the cli build file itself.

  • In the tree, (*) marks a subtree that was already expanded elsewhere, (c) a constraint (not a dependency), and FAILED a node that could not be resolved. The dependencies task prints a legend for (*) and (c) under the tree; the legend of (*) is left out of the listings on this page, and the one of (c) is kept.

"Highest" has consequences. The default rule compares versions and nothing else, so a direct declaration of an older version cannot downgrade a library that needs a newer one: the 2.11.0 written in the cli build file is only a request, and it lost to a request that came from deeper in the graph. A newer version is also not always compatible; when the winner is a major version that nobody tested with the library that asked for the older one, the build compiles and the problem appears at run time. The two tools of the sections below, constraints and strictly, are the way to say what you want instead of accepting the default.

How versions are compared

The comparison is not alphabetical and not purely numeric. The versions of a module are split into parts at ., -, _ and +, and also where digits and letters meet (1a1 is 1.a.1). The parts are compared one by one: numbers numerically, a number is higher than a word (1.a is lower than 1.1), words alphabetically and case-sensitively, an extra numeric part makes a version higher (1.1 is lower than 1.1.0), and an extra word makes it lower (1.1.a is lower than 1.1). A few words are special: dev is lower than any other word, and rc, snapshot, final, ga, release and sp are higher than any other word, in that order.

Conflict resolution adds one rule on top of the ordering: it compares base versions first. The part of a version after a non-dot separator is its qualifier (beta in 1.0-beta); Gradle selects the versions with the highest base version, discards all the others, and only then, between versions that share that base, prefers the ones without a qualifier. Two experiments with slf4j-api show both halves. The experiment used a scratch pair of configurations, a dependency scope (where the versions are declared) and a resolvable one that extends it, because the point was only to resolve and read the report.

Kotlin DSL
plugins {
    application
}

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

// A scratch pair of configurations used only to compare version ordering.
val declared = configurations.dependencyScope("declared")
val ordering = configurations.resolvable("ordering") {
    extendsFrom(declared.get())
}

dependencies {
    declared("org.slf4j:slf4j-api:2.0.0-beta1")
    declared("org.slf4j:slf4j-api:2.0.0")
    declared("org.slf4j:slf4j-api:2.0.0-alpha7")
}
Groovy DSL
plugins {
    id 'application'
}

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

// A scratch pair of configurations used only to compare version ordering.
configurations.dependencyScope('declared')
configurations.resolvable('ordering') {
    extendsFrom(configurations.getByName('declared'))
}

dependencies {
    declared 'org.slf4j:slf4j-api:2.0.0-beta1'
    declared 'org.slf4j:slf4j-api:2.0.0'
    declared 'org.slf4j:slf4j-api:2.0.0-alpha7'
}
./gradlew -q :tidepool-cli:dependencyInsight --dependency slf4j-api --configuration ordering
org.slf4j:slf4j-api:2.0.0
  Variant runtime:
   Selection reasons:
      - By conflict resolution: between versions 2.0.0, 2.0.0-beta1 and 2.0.0-alpha7

org.slf4j:slf4j-api:2.0.0
\--- ordering

org.slf4j:slf4j-api:2.0.0-alpha7 -> 2.0.0
\--- ordering

org.slf4j:slf4j-api:2.0.0-beta1 -> 2.0.0
\--- ordering

The release 2.0.0 beat its own beta and alpha: same base version, and the one without a qualifier is preferred. In the second experiment the declarations were 2.0.17 and 2.1.0-alpha1:

org.slf4j:slf4j-api:2.1.0-alpha1
  Variant runtime:
   Selection reasons:
      - By conflict resolution: between versions 2.1.0-alpha1 and 2.0.17

org.slf4j:slf4j-api:2.1.0-alpha1
\--- ordering

org.slf4j:slf4j-api:2.0.17 -> 2.1.0-alpha1
\--- ordering

The alpha won, because its base version 2.1.0 is higher than 2.0.17. Gradle does not know that 2.1.0-alpha1 is a pre-release in any sense that matters for your project, so a transitive dependency on a pre-release can pull one into the build. Rejecting it is what reject and strictly ranges are for (see Platforms, Constraints & Rich Versions).

Ranges take part in the same decision. Without ranges, the highest non-rejected version is selected. When a non-range version fits within a range, or is higher than its upper bound, it is selected. When there are only ranges, the highest version in their intersection is selected if they overlap; otherwise the highest version of the largest range, or a failure if there is no such version. Resolving a range needs the list of versions, which Gradle reads from the repository (see the metadata rules above).

A constraint is a request, not an override

A dependency constraint is a statement about a version that applies only if the module is part of the graph for some other reason. It is the supported way to influence the version of a transitive dependency without making it a direct one. The cli build file below constrains commons-io to 2.21.0:

Kotlin DSL
plugins {
    application
}

dependencies {
    implementation(project(":tidepool-engine"))
    implementation("info.picocli:picocli:4.7.7")
    implementation("commons-io:commons-io:2.11.0")

    constraints {
        implementation("commons-io:commons-io:2.21.0") {
            because("tidepool-cli standardises on commons-io 2.21.0")
        }
    }
}

application {
    mainClass = "io.tidepool.cli.Main"
}
Groovy DSL
plugins {
    id 'application'
}

dependencies {
    implementation project(':tidepool-engine')
    implementation 'info.picocli:picocli:4.7.7'
    implementation 'commons-io:commons-io:2.11.0'

    constraints {
        implementation('commons-io:commons-io:2.21.0') {
            because 'tidepool-cli standardises on commons-io 2.21.0'
        }
    }
}

application {
    mainClass = 'io.tidepool.cli.Main'
}
./gradlew -q :tidepool-cli:dependencies --configuration runtimeClasspath
./gradlew -q :tidepool-cli:dependencyInsight --dependency commons-io --configuration runtimeClasspath
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 -> 2.21.0
|         \--- commons-codec:commons-codec:1.19.0
+--- info.picocli:picocli:4.7.7
+--- commons-io:commons-io:2.11.0 -> 2.21.0
\--- commons-io:commons-io:2.21.0 (c)

(c) - A dependency constraint, not a dependency. The dependency affected by the constraint occurs elsewhere in the tree.
commons-io:commons-io:2.21.0
  Variant runtime:
   Selection reasons:
      - By constraint: tidepool-cli standardises on commons-io 2.21.0
      - By conflict resolution: between versions 2.21.0, 2.11.0 and 2.20.0

commons-io:commons-io:2.21.0
\--- runtimeClasspath

commons-io:commons-io:2.11.0 -> 2.21.0
\--- runtimeClasspath

commons-io:commons-io:2.20.0 -> 2.21.0
\--- org.apache.commons:commons-csv:1.14.1
     \--- project ':tidepool-engine'
          \--- runtimeClasspath

Both requests (2.11.0 from the build file, 2.20.0 from Commons CSV) now resolve to 2.21.0, and the selection reasons list the constraint with its because text, followed by the conflict resolution between all three versions. The constraint participated in the conflict like any other request, and it won because it is the highest. A constraint that is lower than the requests does not lower anything. With 2.15.0 instead of 2.21.0:

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
+--- commons-io:commons-io:2.11.0 -> 2.20.0
\--- commons-io:commons-io:2.15.0 -> 2.20.0 (c)

(c) - A dependency constraint, not a dependency. The dependency affected by the constraint occurs elsewhere in the tree.

the selected version is still 2.20.0, and the constraint ((c)) is shown as 2.15.0 -> 2.20.0. The consequence is a rule worth remembering: constraints can raise the version of a module and can pin the one a platform recommends, but under the default rules they can never lower it. Lowering takes strictly, which is the next section; platforms, BOMs and constraints published by libraries are the subject of Platforms, Constraints & Rich Versions.

strictly: saying exactly which version

A strict version is the strongest declaration. Every version that does not match it is excluded, and, as the documentation puts it, when it is used on a declared dependency it can downgrade a version. The cli build file below makes 2.11.0 the only acceptable commons-io:

Kotlin DSL
plugins {
    application
}

dependencies {
    implementation(project(":tidepool-engine"))
    implementation("info.picocli:picocli:4.7.7")
    implementation("commons-io:commons-io") {
        version {
            strictly("2.11.0")
        }
    }
}

application {
    mainClass = "io.tidepool.cli.Main"
}
Groovy DSL
plugins {
    id 'application'
}

dependencies {
    implementation project(':tidepool-engine')
    implementation 'info.picocli:picocli:4.7.7'
    implementation('commons-io:commons-io') {
        version {
            strictly '2.11.0'
        }
    }
}

application {
    mainClass = 'io.tidepool.cli.Main'
}
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 -> 2.11.0
|         \--- commons-codec:commons-codec:1.19.0
+--- info.picocli:picocli:4.7.7
\--- commons-io:commons-io:{strictly 2.11.0} -> 2.11.0
commons-io:commons-io:2.11.0 (by ancestor)
  Variant runtime:

commons-io:commons-io:{strictly 2.11.0} -> 2.11.0
\--- runtimeClasspath

commons-io:commons-io:2.20.0 -> 2.11.0
\--- org.apache.commons:commons-csv:1.14.1
     \--- project ':tidepool-engine'
          \--- runtimeClasspath

Commons CSV asks for 2.20.0 and gets 2.11.0. The selection report says (by ancestor): the strict version of a node closer to the root overrides what the nodes below it request. There is no conflict resolution in the reasons any more, because there was nothing to choose between. A downgrade like this one is a deliberate decision to run Commons CSV with a Commons IO older than the one it was built against; do it only when a test shows that it works.

When strict declarations at the same level disagree there is no right answer, and Gradle says so. The cli build file below declares a strict 2.11.0 on the dependency and a strict 2.12.0 in a constraints block:

Kotlin DSL
plugins {
    application
}

dependencies {
    implementation(project(":tidepool-engine"))
    implementation("info.picocli:picocli:4.7.7")
    implementation("commons-io:commons-io") {
        version {
            strictly("2.11.0")
        }
    }
    constraints {
        implementation("commons-io:commons-io") {
            version {
                strictly("2.12.0")
            }
        }
    }
}

application {
    mainClass = "io.tidepool.cli.Main"
}
Groovy DSL
plugins {
    id 'application'
}

dependencies {
    implementation project(':tidepool-engine')
    implementation 'info.picocli:picocli:4.7.7'
    implementation('commons-io:commons-io') {
        version {
            strictly '2.11.0'
        }
    }
    constraints {
        implementation('commons-io:commons-io') {
            version {
                strictly '2.12.0'
            }
        }
    }
}

application {
    mainClass = 'io.tidepool.cli.Main'
}
./gradlew -q :tidepool-cli:dependencies --configuration runtimeClasspath
./gradlew --no-configuration-cache :tidepool-cli:compileJava
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 FAILED
|         \--- commons-codec:commons-codec:1.19.0
+--- info.picocli:picocli:4.7.7
+--- commons-io:commons-io:{strictly 2.11.0} FAILED
\--- commons-io:commons-io:{strictly 2.12.0} FAILED
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').
> Could not resolve all files for configuration ':tidepool-cli:compileClasspath'.
   > Could not resolve commons-io:commons-io:{strictly 2.11.0}.
     Required by:
         project ':tidepool-cli'
      > Cannot find a version of 'commons-io:commons-io' that satisfies the version constraints:
           Dependency path: project ':tidepool-cli' (compileClasspath) --> 'commons-io:commons-io:{strictly 2.11.0}'
           Constraint path: project ':tidepool-cli' (compileClasspath) --> 'commons-io:commons-io:{strictly 2.12.0}'

> There is 1 more failure with an identical cause.

The dependencies task reports the problem but does not fail (the nodes are marked FAILED); the task that needs the classpath does, with both the dependency path and the constraint path in the message. The full rules for competing strict declarations at different depths, and for ranges and prefer, are in the documentation and on the platforms page.

Failing instead of choosing: failOnVersionConflict

Some teams prefer to be told about every disagreement instead of accepting the highest version. ResolutionStrategy.failOnVersionConflict() makes any version conflict in a configuration a failure. It is configured per configuration, so configureEach applies it to all of them:

Kotlin DSL
plugins {
    application
}

configurations.configureEach {
    resolutionStrategy.failOnVersionConflict()
}

dependencies {
    implementation(project(":tidepool-engine"))
    implementation("info.picocli:picocli:4.7.7")
    implementation("commons-io:commons-io:2.11.0")
}

application {
    mainClass = "io.tidepool.cli.Main"
}
Groovy DSL
plugins {
    id 'application'
}

configurations.configureEach {
    resolutionStrategy.failOnVersionConflict()
}

dependencies {
    implementation project(':tidepool-engine')
    implementation 'info.picocli:picocli:4.7.7'
    implementation 'commons-io:commons-io:2.11.0'
}

application {
    mainClass = 'io.tidepool.cli.Main'
}
./gradlew --no-configuration-cache :tidepool-cli:compileJava
./gradlew --no-configuration-cache :tidepool-cli:installDist
> Task :tidepool-model:compileJava UP-TO-DATE
> Task :tidepool-engine:compileJava UP-TO-DATE
> Task :tidepool-cli:compileJava UP-TO-DATE

BUILD SUCCESSFUL in 426ms
3 actionable tasks: 3 up-to-date

The first command succeeded. The compile classpath of tidepool-cli has only one request for commons-io, the 2.11.0 that the build file declares: the engine declares Commons CSV with implementation, so its dependencies are not exposed to the compile classpath of a consumer, and there was nothing to conflict. The second command resolves runtimeClasspath, where the engine’s Commons CSV is present, and where the conflict is:

FAILURE: Build failed with an exception.

* What went wrong:
Execution failed for task ':tidepool-cli:startScripts' (registered by plugin 'org.gradle.application').
> Error while evaluating property 'relativeClasspath' of task ':tidepool-cli:startScripts'.
   > Could not resolve all files for configuration ':tidepool-cli:runtimeClasspath'.
      > Could not resolve commons-io:commons-io:2.11.0.
        Required by:
            project ':tidepool-cli'
         > Conflict found for module 'commons-io:commons-io': between versions 2.20.0 and 2.11.0
> There is 1 more failure with an identical cause.

The message names the module and the two versions, and the hint at the end (cut here) suggests the dependencyInsight command that finds the origin of each request. The setting is blunt: a graph of any size has benign version differences, and failing on all of them tends to be turned off again. A common compromise is to enable it only in a CI job or in a verification task, and to resolve the conflicts it reports with constraints or strictly.

The Javadoc of ResolutionStrategy lists the neighbouring switches: failOnDynamicVersions(), failOnChangingVersions() and failOnNonReproducibleResolution(), which belong to dependency locking and are covered on the locking page.

The other ways out

The default rule, the constraint and strictly are the tools of the dependency graph itself. Gradle has more, and each lives on the page named in the last column.

Need Tool Where

Raise the version of a transitive dependency

A constraint on the module, or a platform that recommends it

This page; Platforms, Constraints & Rich Versions

Run exactly one version, whatever the graph says

strictly on the declared dependency

This page

Keep several libraries on one version

A platform or BOM, or version alignment

Platforms, Constraints & Rich Versions

Force a version for a whole configuration, or replace one module by another or by a project

resolutionStrategy.force, eachDependency, dependency substitution

Resolution Rules & Substitution

Fix wrong metadata (a missing or unnecessary dependency in someone’s POM)

A component metadata rule

Resolution Rules & Substitution

Freeze the whole result and detect change

Dependency locking

Dependency Locking, Verification & Caching

Remove a dependency that should not be there

exclude, isTransitive = false

The section "Excluding transitive dependencies" below

The same graph in Maven

Maven resolves the same graph with a different rule: nearest definition. The version closest to the root of the graph wins, and the first declaration wins at equal depth. The Maven 3.10.0 equivalent of the cli build is a POM with the same two dependencies:

<dependencies>
  <dependency>
    <groupId>org.apache.commons</groupId>
    <artifactId>commons-csv</artifactId>
    <version>1.14.1</version>
  </dependency>
  <dependency>
    <groupId>commons-io</groupId>
    <artifactId>commons-io</artifactId>
    <version>2.11.0</version>
  </dependency>
</dependencies>
mvn -B org.apache.maven.plugins:maven-dependency-plugin:3.11.0:tree -Dverbose
[INFO] io.tidepool:tidepool-cli:jar:1.0.0
[INFO] +- org.apache.commons:commons-csv:jar:1.14.1:compile
[INFO] |  +- (commons-io:commons-io:jar:2.20.0:compile - omitted for conflict with 2.11.0)
[INFO] |  \- commons-codec:commons-codec:jar:1.19.0:compile
[INFO] \- commons-io:commons-io:jar:2.11.0:compile

Maven kept 2.11.0 because the declaration in the POM is one level from the root while the one in Commons CSV is two. Gradle kept 2.20.0 because it is higher. The two rules agree whenever the direct declaration is also the newest; they disagree exactly when a project pins an older version of something a library needs. Maven’s remedies are a dependencyManagement entry (which does not override a version written in a direct dependency), exclusions, and the Enforcer rules dependencyConvergence and requireUpperBoundDeps, which fail the build instead of choosing; they are on Maven Dependencies, Scopes & Mediation, Dependency Management & BOMs and Dependency Analysis & Convergence. In Ant, Ivy picks a conflict manager per module; its default, latest-revision, behaves like Gradle’s rule and nearest (since Ivy 2.6.0) behaves like Maven’s, as Ivy: Conflicts & Versions shows. The Maven output above has the line Loaded 74 auto-discovered prefixes for remote repository ... removed.

Capability conflicts

What a capability is

Version conflicts are about one module at two versions. A different failure is two different modules that do the same job: two logging bindings, two connection pools, a library and its relocated successor. Their coordinates differ, so a tool that looks only at coordinates (Maven, or Gradle’s version conflict logic) sees no conflict, and both end up on the classpath. Gradle’s answer is the capability: a name, written as group, name and version, that a component declares in order to say "I provide this functionality". It is illegal for a dependency graph to contain two components that provide the same capability; Gradle fails the build and names the modules.

Every component has an implicit capability, its own coordinates: org.apache.commons:commons-lang3:3.8 provides the capability org.apache.commons:commons-lang3, at version 3.8. Capabilities are versioned, which is what lets Gradle choose the highest one. A component can declare more capabilities, and doing so is the whole mechanism. Two places can declare them: the metadata of a published component (Gradle Module Metadata has a capabilities section for each variant), and a build, either through a component metadata rule for an external module that did not declare them or through the outgoing configurations of your own project.

A conflict you get for free: Guava and google-collections

Guava is the successor of the Google Collections Library. Guava’s Gradle Module Metadata declares, on each of its variants, the capability com.google.collections:google-collections next to its own, so a build that has both Guava and the old library fails without any rule being written. The metadata of com.google.guava:guava:33.5.0-jre (the file guava-33.5.0-jre.module, fetched from Maven Central) has this in each variant:

      "capabilities": [
        {
          "group": "com.google.guava",
          "name": "guava",
          "version": "33.5.0-jre"
        },
        {
          "group": "com.google.collections",
          "name": "google-collections",
          "version": "33.5.0-jre"
        }
      ]

(The listing is the capabilities entry of the first variant of the file; the three other variants repeat it. Note that the capability version of google-collections is the version of Guava, which is what makes Guava the highest provider.) The cli build file declares both libraries:

Kotlin DSL
plugins {
    application
}

dependencies {
    implementation(project(":tidepool-engine"))
    implementation("info.picocli:picocli:4.7.7")
    implementation("com.google.guava:guava:33.5.0-jre")
    implementation("com.google.collections:google-collections:1.0")
}

application {
    mainClass = "io.tidepool.cli.Main"
}
Groovy DSL
plugins {
    id 'application'
}

dependencies {
    implementation project(':tidepool-engine')
    implementation 'info.picocli:picocli:4.7.7'
    implementation 'com.google.guava:guava:33.5.0-jre'
    implementation 'com.google.collections:google-collections:1.0'
}

application {
    mainClass = 'io.tidepool.cli.Main'
}
./gradlew --no-configuration-cache :tidepool-cli:compileJava
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').
> Could not resolve all files for configuration ':tidepool-cli:compileClasspath'.
   > Could not resolve com.google.guava:guava:33.5.0-jre.
     Required by:
         project ':tidepool-cli'
      > Module 'com.google.guava:guava' has been rejected:
           Cannot select module because of conflict with com.google.collections:google-collections:1.0 (compile). Both provide capability 'com.google.collections:google-collections:33.5.0-jre'.
   > Could not resolve com.google.collections:google-collections:1.0.
     Required by:
         project ':tidepool-cli'
      > Module 'com.google.collections:google-collections' has been rejected:
           Cannot select module because of conflict with com.google.guava:guava:33.5.0-jre (jreApiElements). Both provide capability 'com.google.collections:google-collections:1.0'.

* Try:
> Capability conflicts are explained in more detail at https://docs.gradle.org/9.8.1/userguide/component_capabilities.html#sub:capabilities.
> Use 'resolutionStrategy.capabilitiesResolution' to choose between conflicting capability providers, as described at https://docs.gradle.org/9.8.1/userguide/component_capabilities.html#sec:selecting-between-candidates.

Read the message once: it names both modules, the capability on which they collide (com.google.collections:google-collections), and the variant that provides it (compile for the old library, which has only POM metadata, and jreApiElements for Guava). It also prints two hints. The first one links the section of the documentation with the anchor sub:capabilities, which is not an anchor of that page (its section is sec:capabilities), so the link lands at the top of the page; the second hint is the useful one, resolutionStrategy.capabilitiesResolution. The reports show the same: the dependencies task marks both nodes FAILED.

Choosing between candidates

resolutionStrategy.capabilitiesResolution.withCapability(...) receives an action that is run whenever a conflict on that capability appears. The action has the list of candidates, and can call select(candidate) for one of them, or selectHighestVersion(), which compares the capability versions (here Guava’s 33.5.0-jre against the old library’s 1.0), and may call because(...) to leave a reason that appears in the reports.

Kotlin DSL
plugins {
    application
}

configurations.configureEach {
    resolutionStrategy.capabilitiesResolution.withCapability("com.google.collections:google-collections") {
        selectHighestVersion()
    }
}

dependencies {
    implementation(project(":tidepool-engine"))
    implementation("info.picocli:picocli:4.7.7")
    implementation("com.google.guava:guava:33.5.0-jre")
    implementation("com.google.collections:google-collections:1.0")
}

application {
    mainClass = "io.tidepool.cli.Main"
}
Groovy DSL
plugins {
    id 'application'
}

configurations.configureEach {
    resolutionStrategy.capabilitiesResolution.withCapability('com.google.collections:google-collections') {
        selectHighestVersion()
    }
}

dependencies {
    implementation project(':tidepool-engine')
    implementation 'info.picocli:picocli:4.7.7'
    implementation 'com.google.guava:guava:33.5.0-jre'
    implementation 'com.google.collections:google-collections:1.0'
}

application {
    mainClass = 'io.tidepool.cli.Main'
}
./gradlew -q :tidepool-cli:dependencies --configuration runtimeClasspath
./gradlew -q :tidepool-cli:dependencyInsight --dependency guava:guava --configuration runtimeClasspath
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
+--- 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
\--- com.google.collections:google-collections:1.0 -> com.google.guava:guava:33.5.0-jre (*)
com.google.guava:guava:33.5.0-jre
  Variant jreRuntimeElements:
   Selection reasons:
      - By conflict resolution: latest version of capability com.google.collections:google-collections

com.google.collections:google-collections:1.0 -> com.google.guava:guava:33.5.0-jre
\--- runtimeClasspath

com.google.guava:guava:33.5.0-jre
\--- runtimeClasspath

Guava was selected, and the request for google-collections was replaced by Guava: the arrow google-collections:1.0 -> com.google.guava:guava:33.5.0-jre is a request for one module that resolved to another, which is how a build ends up with Guava’s classes in place of the old ones. The reason is latest version of capability. When the highest version is the wrong criterion (for logging frameworks any version of the right binding will do), choose the candidate by identity and say why:

Kotlin DSL
plugins {
    application
}

configurations.configureEach {
    resolutionStrategy.capabilitiesResolution.withCapability("com.google.collections:google-collections") {
        val guava = candidates.first {
            val id = it.id
            id is ModuleComponentIdentifier && id.module == "guava"
        }
        select(guava)
        because("Guava is the successor of google-collections")
    }
}

dependencies {
    implementation(project(":tidepool-engine"))
    implementation("info.picocli:picocli:4.7.7")
    implementation("com.google.guava:guava:33.5.0-jre")
    implementation("com.google.collections:google-collections:1.0")
}

application {
    mainClass = "io.tidepool.cli.Main"
}
Groovy DSL
plugins {
    id 'application'
}

configurations.configureEach {
    resolutionStrategy.capabilitiesResolution.withCapability('com.google.collections:google-collections') {
        def guava = candidates.find {
            it.id instanceof ModuleComponentIdentifier && it.id.module == 'guava'
        }
        select(guava)
        because 'Guava is the successor of google-collections'
    }
}

dependencies {
    implementation project(':tidepool-engine')
    implementation 'info.picocli:picocli:4.7.7'
    implementation 'com.google.guava:guava:33.5.0-jre'
    implementation 'com.google.collections:google-collections:1.0'
}

application {
    mainClass = 'io.tidepool.cli.Main'
}
com.google.guava:guava:33.5.0-jre
  Variant jreRuntimeElements:
   Selection reasons:
      - By conflict resolution: On capability com.google.collections:google-collections Guava is the successor of google-collections

com.google.collections:google-collections:1.0 -> com.google.guava:guava:33.5.0-jre
\--- runtimeClasspath

com.google.guava:guava:33.5.0-jre
\--- runtimeClasspath

The reason now carries the text of because. The build compiled (BUILD SUCCESSFUL) and the configuration cache stored the entry, which means that this resolution rule is configuration-cache compatible. The documentation lists the limits of select: it accepts only a module that is currently a candidate in that conflict, select(null) is an error, and if the module you prefer takes part in another conflict the one you are resolving stays unresolved and the build fails.

Maven has no capabilities. The Maven 3.10.0 resolution of the same two dependencies keeps both, and the duplicate classes are discovered at run time:

[INFO] io.tidepool:tidepool-cli:jar:1.0.0
[INFO] +- com.google.guava:guava:jar:33.5.0-jre:compile
[INFO] |  +- com.google.guava:failureaccess:jar:1.0.3:compile
[INFO] |  +- com.google.guava:listenablefuture:jar:9999.0-empty-to-avoid-conflict-with-guava:compile
[INFO] |  +- org.jspecify:jspecify:jar:1.0.0:compile
[INFO] |  +- com.google.errorprone:error_prone_annotations:jar:2.41.0:compile
[INFO] |  \- com.google.j2objc:j2objc-annotations:jar:3.1:compile
[INFO] \- com.google.collections:google-collections:jar:1.0:compile

The listenablefuture entry in the middle of the tree shows a version-based trick for a related problem: Guava depends on a listenablefuture whose version is 9999.0-empty-to-avoid-conflict-with-guava, a version number that, by its name, is meant to win every version comparison against the standalone ListenableFuture library. It works by version conflict resolution; the capability on google-collections is the mechanism that works across different coordinates.

Declaring capabilities for an external module

Most libraries do not declare capabilities, so the conflict must be declared by the build. The two modules of the documentation’s example are log4j:log4j (Log4j 1.x) and org.slf4j:log4j-over-slf4j (a bridge that supplies the Log4j 1 API on top of SLF4J): different coordinates, same API, and both are fatal on one classpath. A ComponentMetadataRule can add a capability to every variant of both. The components.all call registers the rule. The rule is a class and not a closure, which is what keeps the build compatible with the configuration cache (the build ran with it, and stored the entry); a rule can also be marked @CacheableRule so that Gradle caches its result, as described on Resolution Rules & Substitution.

Kotlin DSL
plugins {
    application
}

// Metadata rule: two unrelated coordinates provide the same capability.
class Log4jApiCapability : ComponentMetadataRule {
    override fun execute(context: ComponentMetadataContext) = context.details.run {
        if (id.name == "log4j" || id.name == "log4j-over-slf4j") {
            allVariants {
                withCapabilities {
                    addCapability("io.tidepool", "log4j-api", id.version)
                }
            }
        }
    }
}

dependencies {
    components.all<Log4jApiCapability>()

    implementation(project(":tidepool-engine"))
    implementation("info.picocli:picocli:4.7.7")
    implementation("log4j:log4j:1.2.17")
    implementation("org.slf4j:log4j-over-slf4j:2.0.17")
}

application {
    mainClass = "io.tidepool.cli.Main"
}
Groovy DSL
import groovy.transform.CompileStatic

plugins {
    id 'application'
}

// Metadata rule: two unrelated coordinates provide the same capability.
@CompileStatic
class Log4jApiCapability implements ComponentMetadataRule {
    void execute(ComponentMetadataContext context) {
        context.details.with {
            if (id.name == 'log4j' || id.name == 'log4j-over-slf4j') {
                allVariants {
                    it.withCapabilities {
                        it.addCapability('io.tidepool', 'log4j-api', id.version)
                    }
                }
            }
        }
    }
}

dependencies {
    components.all(Log4jApiCapability)

    implementation project(':tidepool-engine')
    implementation 'info.picocli:picocli:4.7.7'
    implementation 'log4j:log4j:1.2.17'
    implementation 'org.slf4j:log4j-over-slf4j:2.0.17'
}

application {
    mainClass = 'io.tidepool.cli.Main'
}
./gradlew --no-configuration-cache :tidepool-cli:compileJava
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').
> Could not resolve all files for configuration ':tidepool-cli:compileClasspath'.
   > Could not resolve log4j:log4j:1.2.17.
     Required by:
         project ':tidepool-cli'
      > Module 'log4j:log4j' has been rejected:
           Cannot select module because of conflict with org.slf4j:log4j-over-slf4j:2.0.17 (compile). Both provide capability 'io.tidepool:log4j-api:1.2.17'.
   > Could not resolve org.slf4j:log4j-over-slf4j:2.0.17.
     Required by:
         project ':tidepool-cli'
      > Module 'org.slf4j:log4j-over-slf4j' has been rejected:
           Cannot select module because of conflict with log4j:log4j:1.2.17 (compile). Both provide capability 'io.tidepool:log4j-api:2.0.17'.

The documentation’s version of this rule gives both modules the capability log4j:log4j, the coordinates of the old library itself. The example above uses a neutral name, io.tidepool:log4j-api, for a reason found while testing: with log4j:log4j as the capability and the two libraries both declared directly, Gradle 9.8.1 found no conflict when the old library was declared first, and found it when the bridge was declared first. The same two declarations with addCapability("log4j", "log4j", id.version):

compileClasspath - Compile classpath for source set 'main'.
+--- project ':tidepool-engine'
|    \--- project ':tidepool-model'
+--- info.picocli:picocli:4.7.7
+--- log4j:log4j:1.2.17
\--- org.slf4j:log4j-over-slf4j:2.0.17
     \--- org.slf4j:slf4j-api:2.0.17

(log4j:log4j declared first: no conflict, both libraries on the compile classpath.) With the two lines swapped:

compileClasspath - Compile classpath for source set 'main'.
+--- project ':tidepool-engine'
|    \--- project ':tidepool-model'
+--- info.picocli:picocli:4.7.7
+--- org.slf4j:log4j-over-slf4j:2.0.17 FAILED
\--- log4j:log4j:1.2.17 FAILED

(the bridge declared first: both nodes FAILED, and compileJava fails with the same capability message as above). The Groovy DSL build files behaved the same in both orders, and the documentation’s own variant of the example, with log4j:log4j arriving transitively through ZooKeeper 3.4.9, did fail as the documentation says. The cause was not investigated; the practical rule is to name the capability after neither module, and to check the dependencies report for FAILED instead of assuming that a missing conflict means a missing problem.

Once the capability exists the conflict is resolved as before, with a candidate chosen by identity (a bridge is not an upgrade of the library it replaces, so selectHighestVersion() would be wrong):

Kotlin DSL
plugins {
    application
}

// Metadata rule: two unrelated coordinates provide the same capability.
class Log4jApiCapability : ComponentMetadataRule {
    override fun execute(context: ComponentMetadataContext) = context.details.run {
        if (id.name == "log4j" || id.name == "log4j-over-slf4j") {
            allVariants {
                withCapabilities {
                    addCapability("io.tidepool", "log4j-api", id.version)
                }
            }
        }
    }
}

configurations.configureEach {
    resolutionStrategy.capabilitiesResolution.withCapability("io.tidepool:log4j-api") {
        val bridge = candidates.first {
            val id = it.id
            id is ModuleComponentIdentifier && id.module == "log4j-over-slf4j"
        }
        select(bridge)
        because("tidepool logs through SLF4J")
    }
}

dependencies {
    components.all<Log4jApiCapability>()

    implementation(project(":tidepool-engine"))
    implementation("info.picocli:picocli:4.7.7")
    implementation("log4j:log4j:1.2.17")
    implementation("org.slf4j:log4j-over-slf4j:2.0.17")
}

application {
    mainClass = "io.tidepool.cli.Main"
}
Groovy DSL
import groovy.transform.CompileStatic

plugins {
    id 'application'
}

// Metadata rule: two unrelated coordinates provide the same capability.
@CompileStatic
class Log4jApiCapability implements ComponentMetadataRule {
    void execute(ComponentMetadataContext context) {
        context.details.with {
            if (id.name == 'log4j' || id.name == 'log4j-over-slf4j') {
                allVariants {
                    it.withCapabilities {
                        it.addCapability('io.tidepool', 'log4j-api', id.version)
                    }
                }
            }
        }
    }
}

configurations.configureEach {
    resolutionStrategy.capabilitiesResolution.withCapability('io.tidepool:log4j-api') {
        def bridge = candidates.find {
            it.id instanceof ModuleComponentIdentifier && it.id.module == 'log4j-over-slf4j'
        }
        select(bridge)
        because 'tidepool logs through SLF4J'
    }
}

dependencies {
    components.all(Log4jApiCapability)

    implementation project(':tidepool-engine')
    implementation 'info.picocli:picocli:4.7.7'
    implementation 'log4j:log4j:1.2.17'
    implementation 'org.slf4j:log4j-over-slf4j:2.0.17'
}

application {
    mainClass = 'io.tidepool.cli.Main'
}
./gradlew -q :tidepool-cli:dependencies --configuration runtimeClasspath
./gradlew -q :tidepool-cli:dependencyInsight --dependency log4j --configuration runtimeClasspath
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
+--- log4j:log4j:1.2.17 -> org.slf4j:log4j-over-slf4j:2.0.17
|    \--- org.slf4j:slf4j-api:2.0.17
\--- org.slf4j:log4j-over-slf4j:2.0.17 (*)
org.slf4j:log4j-over-slf4j:2.0.17
  Variant runtime:
   Selection reasons:
      - By conflict resolution: On capability io.tidepool:log4j-api tidepool logs through SLF4J

log4j:log4j:1.2.17 -> org.slf4j:log4j-over-slf4j:2.0.17
\--- runtimeClasspath

org.slf4j:log4j-over-slf4j:2.0.17
\--- runtimeClasspath

Log4j 1 is gone and the bridge, with its slf4j-api, took its place. Both modules publish POM metadata only (the bridge has no .module file on Maven Central), which is why a rule is needed at all. The documentation recommends packaging rules like this one in a plugin, so that every project of an organisation shares them (see Convention Plugins & Sharing Build Logic).

Declaring capabilities on your own components

A project that publishes a library can declare the capabilities of its own variants. Capabilities live on consumable configurations (apiElements and runtimeElements of a Java library). The tidepool build gains a second implementation of the model, tidepool-model-lite, which is an alternative to tidepool-model: both provide the capability io.tidepool:tide-api. One rule from the documentation applies: as soon as any explicit capability is declared, all of them must be, including the implicit one, so each project lists its own coordinates as well.

The settings file includes the new project:

Kotlin DSL
rootProject.name = "tidepool"

dependencyResolutionManagement {
    repositoriesMode = RepositoriesMode.FAIL_ON_PROJECT_REPOS
    repositories {
        mavenCentral()
    }
}

include("tidepool-model", "tidepool-model-lite", "tidepool-engine", "tidepool-cli")
Groovy DSL
rootProject.name = 'tidepool'

dependencyResolutionManagement {
    repositoriesMode = RepositoriesMode.FAIL_ON_PROJECT_REPOS
    repositories {
        mavenCentral()
    }
}

include 'tidepool-model', 'tidepool-model-lite', 'tidepool-engine', 'tidepool-cli'

and both projects carry the same two capabilities (the file of tidepool-model-lite differs only in the name):

Kotlin DSL
plugins {
    `java-library`
}

// Once a capability is declared explicitly, the implicit one (the project's own
// group:name:version) must be declared too.
configurations {
    apiElements {
        outgoing {
            capability("${project.group}:tidepool-model:${project.version}")
            capability("${project.group}:tide-api:${project.version}")
        }
    }
    runtimeElements {
        outgoing {
            capability("${project.group}:tidepool-model:${project.version}")
            capability("${project.group}:tide-api:${project.version}")
        }
    }
}
Groovy DSL
plugins {
    id 'java-library'
}

// Once a capability is declared explicitly, the implicit one (the project's own
// group:name:version) must be declared too.
configurations {
    apiElements {
        outgoing {
            capability("${project.group}:tidepool-model:${project.version}")
            capability("${project.group}:tide-api:${project.version}")
        }
    }
    runtimeElements {
        outgoing {
            capability("${project.group}:tidepool-model:${project.version}")
            capability("${project.group}:tide-api:${project.version}")
        }
    }
}
Kotlin DSL
plugins {
    `java-library`
}

// Once a capability is declared explicitly, the implicit one (the project's own
// group:name:version) must be declared too.
configurations {
    apiElements {
        outgoing {
            capability("${project.group}:tidepool-model-lite:${project.version}")
            capability("${project.group}:tide-api:${project.version}")
        }
    }
    runtimeElements {
        outgoing {
            capability("${project.group}:tidepool-model-lite:${project.version}")
            capability("${project.group}:tide-api:${project.version}")
        }
    }
}
Groovy DSL
plugins {
    id 'java-library'
}

// Once a capability is declared explicitly, the implicit one (the project's own
// group:name:version) must be declared too.
configurations {
    apiElements {
        outgoing {
            capability("${project.group}:tidepool-model-lite:${project.version}")
            capability("${project.group}:tide-api:${project.version}")
        }
    }
    runtimeElements {
        outgoing {
            capability("${project.group}:tidepool-model-lite:${project.version}")
            capability("${project.group}:tide-api:${project.version}")
        }
    }
}

The cli build file depends on the engine, which depends on the model, and on the lite implementation:

Kotlin DSL
plugins {
    application
}

dependencies {
    implementation(project(":tidepool-engine"))
    implementation(project(":tidepool-model-lite"))
    implementation("info.picocli:picocli:4.7.7")
}

application {
    mainClass = "io.tidepool.cli.Main"
}
Groovy DSL
plugins {
    id 'application'
}

dependencies {
    implementation project(':tidepool-engine')
    implementation project(':tidepool-model-lite')
    implementation 'info.picocli:picocli:4.7.7'
}

application {
    mainClass = 'io.tidepool.cli.Main'
}
./gradlew --no-configuration-cache :tidepool-cli:compileJava
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-model-lite'.
     Required by:
         project ':tidepool-cli'
      > Module 'io.tidepool:tidepool-model-lite' has been rejected:
           Cannot select module because of conflict with project ':tidepool-model' (apiElements). Both provide capability 'io.tidepool:tide-api:1.0.0'.
   > Could not resolve project ':tidepool-model'.
     Required by:
         project ':tidepool-cli' > project ':tidepool-engine'
      > Module 'io.tidepool:tidepool-model' has been rejected:
           Cannot select module because of conflict with project ':tidepool-model-lite' (apiElements). Both provide capability 'io.tidepool:tide-api:1.0.0'.

The conflict is between two projects, and the message says so (project ':tidepool-model'). It names the capability and, once more, shows that conflicts on a capability do not care whether the two providers are published or built locally. Choosing the model project resolves it, and the program runs:

Kotlin DSL
plugins {
    application
}

configurations.configureEach {
    resolutionStrategy.capabilitiesResolution.withCapability("io.tidepool:tide-api") {
        val full = candidates.first {
            val id = it.id
            id is ProjectComponentIdentifier && id.projectPath == ":tidepool-model"
        }
        select(full)
        because("tidepool-model is the reference implementation of tide-api")
    }
}

dependencies {
    implementation(project(":tidepool-engine"))
    implementation(project(":tidepool-model-lite"))
    implementation("info.picocli:picocli:4.7.7")
}

application {
    mainClass = "io.tidepool.cli.Main"
}
Groovy DSL
plugins {
    id 'application'
}

configurations.configureEach {
    resolutionStrategy.capabilitiesResolution.withCapability('io.tidepool:tide-api') {
        def full = candidates.find {
            it.id instanceof ProjectComponentIdentifier && it.id.projectPath == ':tidepool-model'
        }
        select(full)
        because 'tidepool-model is the reference implementation of tide-api'
    }
}

dependencies {
    implementation project(':tidepool-engine')
    implementation project(':tidepool-model-lite')
    implementation 'info.picocli:picocli:4.7.7'
}

application {
    mainClass = 'io.tidepool.cli.Main'
}
./gradlew -q :tidepool-cli:dependencies --configuration runtimeClasspath
./gradlew -q :tidepool-cli:dependencyInsight --dependency tidepool-model --configuration runtimeClasspath
./gradlew :tidepool-cli:run --args="../data/stations.csv SF"
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
+--- project ':tidepool-model-lite' -> project ':tidepool-model'
\--- info.picocli:picocli:4.7.7
project ':tidepool-model'
  Variant runtimeElements:
   Selection reasons:
      - By conflict resolution: On capability io.tidepool:tide-api tidepool-model is the reference implementation of tide-api

project ':tidepool-model'
\--- project ':tidepool-engine'
     \--- runtimeClasspath

project ':tidepool-model-lite' -> project ':tidepool-model'
\--- runtimeClasspath
> Task :tidepool-cli:processResources NO-SOURCE
> Task :tidepool-model:processResources NO-SOURCE
> Task :tidepool-engine: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
> Task :tidepool-engine:classes
> Task :tidepool-cli:compileJava UP-TO-DATE
> Task :tidepool-cli:classes UP-TO-DATE
> Task :tidepool-engine:jar UP-TO-DATE

> Task :tidepool-cli:run
San Francisco: -0,869 m

BUILD SUCCESSFUL in 516ms
6 actionable tasks: 2 executed, 4 up-to-date

The project ':tidepool-model-lite' -> project ':tidepool-model' line is the replacement, and the last command ran the application on the model project’s classes. The outgoingVariants report shows the capabilities of a project’s consumable configurations, which is where to look when a conflict message surprises you:

./gradlew -q :tidepool-model:outgoingVariants --variant apiElements
--------------------------------------------------
Variant apiElements
--------------------------------------------------
API elements for the 'main' feature.

Capabilities
    - io.tidepool:tide-api:1.0.0
    - io.tidepool:tidepool-model:1.0.0
Attributes
    - org.gradle.category            = library
    - org.gradle.dependency.bundling = external
    - org.gradle.jvm.version         = 26
    - org.gradle.libraryelements     = jar
    - org.gradle.usage               = java-api
Artifacts
    - build/libs/tidepool-model-1.0.0.jar (artifactType = jar)

(The report goes on with the secondary variants; they were left out.) Since Gradle 8.0 (deprecated before) it is an error for two consumable configurations of one project to have the same attributes and the same capabilities, because a consumer could not tell them apart; a capability is part of what makes a variant unique.

Publishing capabilities

Capabilities travel in Gradle Module Metadata and have no place in a POM or an ivy.xml. Publishing the model project to a repository under the scratch directory (a file: repository, see the local repository in the build file below) shows both facts:

Kotlin DSL
plugins {
    `java-library`
    `maven-publish`
}

// Once a capability is declared explicitly, the implicit one (the project's own
// group:name:version) must be declared too.
configurations {
    apiElements {
        outgoing {
            capability("${project.group}:tidepool-model:${project.version}")
            capability("${project.group}:tide-api:${project.version}")
        }
    }
    runtimeElements {
        outgoing {
            capability("${project.group}:tidepool-model:${project.version}")
            capability("${project.group}:tide-api:${project.version}")
        }
    }
}

publishing {
    publications {
        create<MavenPublication>("maven") {
            from(components["java"])
        }
    }
    repositories {
        maven {
            name = "local"
            url = uri(layout.buildDirectory.dir("repo"))
        }
    }
}
Groovy DSL
plugins {
    id 'java-library'
    id 'maven-publish'
}

// Once a capability is declared explicitly, the implicit one (the project's own
// group:name:version) must be declared too.
configurations {
    apiElements {
        outgoing {
            capability("${project.group}:tidepool-model:${project.version}")
            capability("${project.group}:tide-api:${project.version}")
        }
    }
    runtimeElements {
        outgoing {
            capability("${project.group}:tidepool-model:${project.version}")
            capability("${project.group}:tide-api:${project.version}")
        }
    }
}

publishing {
    publications {
        create('maven', MavenPublication) {
            from components.java
        }
    }
    repositories {
        maven {
            name = 'local'
            url = uri(layout.buildDirectory.dir('repo'))
        }
    }
}
./gradlew :tidepool-model:publishAllPublicationsToLocalRepository
Maven publication 'maven' pom metadata warnings (silence with 'suppressPomMetadataWarningsFor(variant)'):
  - Variant apiElements:
      - Declares capability io.tidepool:tide-api:1.0.0 which cannot be mapped to Maven
  - Variant runtimeElements:
      - Declares capability io.tidepool:tide-api:1.0.0 which cannot be mapped to Maven
These issues indicate information that is lost in the published 'pom' metadata file, which may be an issue if the published library is consumed by an old Gradle version or Apache Maven.
The 'module' metadata file, which is used by Gradle 6+ is not affected.

Gradle warns that the POM cannot carry the capability, and says that the .module file, read by Gradle 6 and later, is not affected. The warning appeared four times in the console (once for each task that renders the metadata); the listing shows one. The generated tidepool-model-1.0.0.module has the capabilities on each variant:

      "capabilities": [
        {
          "group": "io.tidepool",
          "name": "tide-api",
          "version": "1.0.0"
        },
        {
          "group": "io.tidepool",
          "name": "tidepool-model",
          "version": "1.0.0"
        }
      ]

A Maven or an old Gradle consumer of that library will not see the second capability, and will not get the conflict detection. Publishing in depth, including how to silence the warning with suppressPomMetadataWarningsFor, is on Publishing and Variant-Aware Resolution & Module Metadata.

Excluding transitive dependencies

An exclusion removes a module from the graph. It is the bluntest of the tools, and the one that is easiest to overuse: the library that declared the dependency needed it, and removing it is a decision that only a test can validate. The documentation lists the legitimate reasons: a library includes dependencies your application does not need, you want to shrink what you ship, or a conflict can only be solved by not having the module. For the last case, constraints, a metadata rule or a capability are usually better.

Excluding from one dependency

exclude takes a group and a module, either of which may be omitted, and applies to the dependency it is declared on. The cli build file below excludes commons-codec from its own declaration of Commons CSV:

Kotlin DSL
plugins {
    application
}

dependencies {
    implementation(project(":tidepool-engine"))
    implementation("info.picocli:picocli:4.7.7")
    implementation("org.apache.commons:commons-csv:1.14.1") {
        exclude(group = "commons-codec", module = "commons-codec")
    }
}

application {
    mainClass = "io.tidepool.cli.Main"
}
Groovy DSL
plugins {
    id 'application'
}

dependencies {
    implementation project(':tidepool-engine')
    implementation 'info.picocli:picocli:4.7.7'
    implementation('org.apache.commons:commons-csv:1.14.1') {
        exclude group: 'commons-codec', module: 'commons-codec'
    }
}

application {
    mainClass = 'io.tidepool.cli.Main'
}
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.apache.commons:commons-csv:1.14.1 (*)

Commons Codec is still there, under commons-csv. The exclusion changed nothing visible, because the tidepool-engine project declares Commons CSV without it, and an exclusion applies only if every declaration that leads to the module agrees: if another dependency in the graph still needs the module, Gradle keeps it. The Javadoc of ModuleDependency.exclude says the same and adds the remedy: to guarantee that a transitive dependency is excluded from a whole classpath, use a per-configuration exclude rule, which is the next form.

Excluding from a whole configuration

An exclude on a configuration applies to everything that is resolved through it. With configureEach it applies to all the configurations of the project:

Kotlin DSL
plugins {
    application
}

// Applies to every configuration of tidepool-cli, whichever dependency brings the module in.
configurations.configureEach {
    exclude(group = "commons-codec", module = "commons-codec")
}

dependencies {
    implementation(project(":tidepool-engine"))
    implementation("info.picocli:picocli:4.7.7")
}

application {
    mainClass = "io.tidepool.cli.Main"
}
Groovy DSL
plugins {
    id 'application'
}

// Applies to every configuration of tidepool-cli, whichever dependency brings the module in.
configurations.configureEach {
    exclude group: 'commons-codec', module: 'commons-codec'
}

dependencies {
    implementation project(':tidepool-engine')
    implementation 'info.picocli:picocli:4.7.7'
}

application {
    mainClass = 'io.tidepool.cli.Main'
}
./gradlew -q :tidepool-cli:dependencies --configuration runtimeClasspath
./gradlew -q :tidepool-cli:run --args="../data/stations.csv SF"
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
\--- info.picocli:picocli:4.7.7

The dependency is gone from the graph, and the exclusion followed the module on every path. This is also where the cost of exclusions shows: the application compiled without complaint, but the engine’s use of Commons CSV needs Commons Codec at run time, and the program fails as soon as it reads the file:

Exception in thread "main" java.lang.NoClassDefFoundError: org/apache/commons/codec/binary/Base64OutputStream
	at io.tidepool.engine.StationReader.read(StationReader.java:19)
	at io.tidepool.cli.Main.call(Main.java:24)
	at io.tidepool.cli.Main.call(Main.java:15)
	at picocli.CommandLine.executeUserObject(CommandLine.java:2031)
	at picocli.CommandLine.access$1500(CommandLine.java:148)
	at picocli.CommandLine$RunLast.executeUserObjectOfLastSubcommandWithSameParent(CommandLine.java:2469)
	at picocli.CommandLine$RunLast.handle(CommandLine.java:2461)
	...
Caused by: java.lang.ClassNotFoundException: org.apache.commons.codec.binary.Base64OutputStream
	...
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

(The stack trace was cut after its first seven frames, the tail ... 12 more of the cause was replaced by ..., and the generic hints at the end were cut.) The failure is a NoClassDefFoundError at run time, not a resolution error, because nothing in the graph told Gradle that the exclusion was wrong. The same happens with the third form of exclusion, a dependency that is not transitive at all. In tidepool-engine:

Kotlin DSL
plugins {
    `java-library`
}

dependencies {
    api(project(":tidepool-model"))
    implementation("org.apache.commons:commons-csv:1.14.1") {
        isTransitive = false
    }
}
Groovy DSL
plugins {
    id 'java-library'
}

dependencies {
    api project(':tidepool-model')
    implementation('org.apache.commons:commons-csv:1.14.1') {
        transitive = false
    }
}

the dependencies of Commons CSV (both of them) are gone from the runtime classpath of every consumer of the engine, and the program fails the same way, this time in Commons IO:

runtimeClasspath - Runtime classpath of source set 'main'.
+--- project ':tidepool-engine'
|    +--- project ':tidepool-model'
|    \--- org.apache.commons:commons-csv:1.14.1
\--- info.picocli:picocli:4.7.7
Exception in thread "main" java.lang.NoClassDefFoundError: org/apache/commons/io/output/AppendableOutputStream
	at io.tidepool.engine.StationReader.read(StationReader.java:19)
	at io.tidepool.cli.Main.call(Main.java:24)
	at io.tidepool.cli.Main.call(Main.java:15)
	at picocli.CommandLine.executeUserObject(CommandLine.java:2031)
	at picocli.CommandLine.access$1500(CommandLine.java:148)
	at picocli.CommandLine$RunLast.executeUserObjectOfLastSubcommandWithSameParent(CommandLine.java:2469)
	at picocli.CommandLine$RunLast.handle(CommandLine.java:2461)
	...
Caused by: java.lang.ClassNotFoundException: org.apache.commons.io.output.AppendableOutputStream
	...
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

isTransitive = false (written transitive = false in the Groovy DSL) is an exclusion of everything below a dependency; use it for a dependency that is genuinely a single JAR, or for a project dependency whose own dependencies must not leak.

Choosing the right tool

The documentation closes its guide with a list of alternatives to try before an exclusion: a constraint if the problem is a version, a component metadata rule if the metadata of the library is wrong (for example, a compile-time dependency that was published as a runtime one), and a capability if two modules are alternatives to each other. An exclusion remains right for the case it was designed for: a module that is on the classpath and should not be, and that no one needs.

Maven’s <exclusions> element is the same device with the same limits (it applies per dependency; Maven also accepts the wildcard * for group and artifact), and Ivy has <exclude>; both are covered in Maven Dependencies, Scopes & Mediation and Ivy: Module Descriptors & Configurations.

Reading a resolution failure

The failures on this page follow a few shapes. This table is a quick way to map one to its cause; each message was produced by a run above.

What Gradle printed What it means What to do

Conflict found for module 'commons-io:commons-io': between versions 2.20.0 and 2.11.0

failOnVersionConflict() is on and two versions were requested.

Pick one with a constraint or strictly, or accept the highest and disable the check.

Cannot find a version of 'commons-io:commons-io' that satisfies the version constraints

Two strict declarations at the same level ask for versions that exclude each other.

Make one of them non-strict, or change one of the versions.

Module 'x' has been rejected: Cannot select module because of conflict with y ... Both provide capability 'g:n:v'

Two modules provide one capability.

Choose a provider with capabilitiesResolution, or remove one of the modules.

a:b:1.0 -> c:d:2.0 in a tree

The request for a:b resolved to a different module (a capability replacement or a substitution).

Check dependencyInsight for the reason, which names the capability or the rule.

a:b:1.0 -> 2.0 in a tree

The request for 1.0 was resolved to 2.0 by conflict resolution, a constraint or a rule.

Read the selection reasons.

A NoClassDefFoundError after adding an exclude

The excluded module was needed at run time.

Remove the exclusion, or provide the module another way.

Release history

The page relies on features that arrived over many releases. The dates and the changes come from the Gradle release notes; the 9.0.0 line is the result of searching the upgrading guide for conflict, capability, constraint, exclude and resolutionStrategy.

Release Change relevant to this page

Gradle 1.0 (2012-06-12)

Gradle’s own dependency resolution engine, moving away from Ivy.

Gradle 4.6 (2018-02-28)

Dependency constraints for transitive dependencies; import of BOMs and optional dependencies from POMs.

Gradle 5.3 (2019-03-20)

A dependency can require a capability (requireCapability), used for feature variants.

Gradle 6.0 (2019-11-08)

Component capabilities for detecting and resolving conflicts between mutually exclusive dependencies; Gradle Module Metadata published by default; rich versions stable.

Gradle 8.0 (2023-02-13)

Two consumable configurations of one project with identical attributes and capabilities became an error (deprecated in the 7.x line).

Gradle 8.4 (2023-10-04)

The role-focused factory methods configurations.dependencyScope, resolvable and consumable, used in the version ordering experiment.

Gradle 9.0.0 (2025-07-31)

No change to conflict resolution, constraints, capabilities or exclusions is listed in the upgrading guide (searched on 2026-10-16).

Gradle 9.7.0 (2026-08-06)

Configuration-cache-safe dependency-resolution APIs in tasks (see the configuration cache page).

The release dates were read from the Gradle versions service (services.gradle.org/versions/all).

Gradle 8.14 and 9.8

Not run here: Gradle 8.14.x was not available for this page. Going by the documentation, the resolution behaviour described above (highest version wins, base-version comparison, strictly, capabilities) is the same in 8.14, because none of it appears in the 9.0 upgrading guide. What differs is the way the examples are written: the dependencyScope and resolvable factory methods used in the version ordering experiment exist since 8.4.

Where the books are outdated

Gradle in Action (Muschko, Manning 2014) is written against Gradle 1.7. Its chapter on dependency management introduces the same ideas as this page: the newest version wins, forcing a version, failing on a conflict, and the dependencyInsight report. The ideas survived; the vocabulary around them changed.

Book statement or practice Current status Source

Dependencies are declared on compile, runtime, testCompile and testRuntime

Removed in Gradle 7.0; the configurations are implementation, api, compileOnly, runtimeOnly, testImplementation and testRuntimeOnly. The Groovy line compile 'commons-io:commons-io:2.11.0' now fails with Could not find method compile(); configurations.compile.exclude ... fails with Could not get unknown property 'compile' for configuration container. Both messages were produced here.

Upgrading from Gradle 6.x to 7.0

Newest version wins, force, failOnVersionConflict(), dependencyInsight

Still the default rule and still available. The Kotlin DSL (Gradle 5.0) spells the same calls with isTransitive and exclude(group = ...).

Dependency constraints and conflicts

Exclusions and the transitive flag are the main tools for a bad graph

Constraints (4.6), rich versions and strictly, capabilities (6.0) and component metadata rules are the supported tools; an exclusion is the last resort.

Excluding transitive dependencies

The build is Groovy only, and apply plugin: 'java' is the way to get configurations

The plugins {} block, and the Kotlin DSL as the default for gradle init since 8.2.

Gradle 8.2 release notes