Gradle Resolution Rules & Substitution

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 and was run on JDK 26 on 2026-10-16 (the daemon and the compiler both ran on JDK 26; no toolchain was involved). Every example was run through the wrapper in scratch copies of a small tidepool-feed build, once with Kotlin DSL build files and once with Groovy DSL build files. The console output is identical in both unless the page says otherwise, so each listing is shown once. The scratch copies pointed their wrapper at a local copy of the same distribution archive; the properties file that a real project commits is

distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionSha256Sum=dce76f55f8e251a3a1f130eb120f30b3d271de2b76c9b0729d316b5a1b6dc01f
distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.1-bin.zip
networkTimeout=10000
retries=0
retryBackOffMs=500
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

The commands use --console=plain and -q. Listings of dependencyInsight leave out the table of variant attributes that Gradle prints under each module, and listings of dependencies leave out the closing legend line about the (*) marker, the link to a web-based report and the empty lines; the scratch directory does not appear anywhere (where an error message would have printed it, the prefix is replaced by /work/tidepool-feed). Gradle 8.14.x was not installed here, so statements about it come from the official documentation and are labelled "not run here".

Gradle resolves a dependency graph from what the build declares and what the repositories publish. Sometimes both are wrong for you: a transitive dependency is too old, a library has been renamed, a POM lists a dependency that the library never needs, or you want to run against your own patched copy of a module. Resolution rules are the hooks that change the graph while it is built, without touching the declarations in every project. This page covers the rule APIs. The resolution model itself, conflict resolution and capabilities are in Dependency Resolution, Conflicts & Capabilities; the declarative alternatives (rich versions, constraints and platforms) are in Platforms, Constraints & Rich Versions.

Which rule does which job

Each rule lives in a different place of the model and changes a different thing. Reach for the narrowest one that does the job.

Rule It changes Typical use

resolutionStrategy.force(...)

The version of a module in one configuration, whatever anybody requests.

Pin a transitive dependency up or down.

resolutionStrategy.eachDependency { ... }

The requested group, name and/or version of every dependency, one at a time.

Compute versions from a policy; redirect to another version or module.

resolutionStrategy.dependencySubstitution { ... }

A selector (module or project) into another selector (module or project).

Swap a library for a fork, a local project or a replacement library.

configuration.exclude(...)

Removes a module from the graph of a configuration.

Drop a dependency that the code never touches.

resolutionStrategy.componentSelection { ... }

Which candidate versions a selector may pick.

Reject a broken release for a dynamic version.

dependencies { modules { ... } }

Declares that one module is replaced by another for conflict resolution.

Renamed libraries such as google-collections and Guava.

dependencies { components { ... } }

The metadata (dependencies, variants, capabilities) of a component as read from the repository.

Repair a POM that lists dependencies the library does not need.

The scenario

The page uses a tiny build with two subprojects: app, which fetches tide data feeds, and commons-codec, which stands in for a locally patched copy of a third-party library. The app depends on Apache HttpClient and on Commons Text, so its runtime classpath already contains a few transitive dependencies to play with. Repositories are declared once in the settings file, as the other Gradle pages do, and toolchain auto-provisioning is switched off in gradle.properties.

Kotlin DSL (settings.gradle.kts)
rootProject.name = "tidepool-feed"

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

include("app", "commons-codec")
Groovy DSL (settings.gradle)
rootProject.name = 'tidepool-feed'

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

include 'app', 'commons-codec'
Kotlin DSL (app/build.gradle.kts, the starting point of every step)
plugins {
    application
}

application {
    mainClass = "io.tidepool.feed.Feed"
}

dependencies {
    implementation("org.apache.httpcomponents:httpclient:4.5.14")
    implementation("org.apache.commons:commons-text:1.10.0")
}
Groovy DSL (app/build.gradle, the starting point of every step)
plugins {
    id 'application'
}

application {
    mainClass = 'io.tidepool.feed.Feed'
}

dependencies {
    implementation 'org.apache.httpcomponents:httpclient:4.5.14'
    implementation 'org.apache.commons:commons-text:1.10.0'
}

The commons-codec project only applies the java-library plugin and contains one empty class; it exists so that the project-substitution examples have a target. Without any rule the runtime classpath looks like this.

$ ./gradlew -q :app:dependencies --configuration runtimeClasspath
------------------------------------------------------------
Project ':app'
------------------------------------------------------------
runtimeClasspath - Runtime classpath of source set 'main'.
+--- org.apache.httpcomponents:httpclient:4.5.14
|    +--- org.apache.httpcomponents:httpcore:4.4.16
|    +--- commons-logging:commons-logging:1.2
|    \--- commons-codec:commons-codec:1.11
\--- org.apache.commons:commons-text:1.10.0
     \--- org.apache.commons:commons-lang3:3.12.0

Every step below appends its rule to the app build file (the dependencies block above stays) and runs the same report. The two reports that explain a decision are dependencies (the tree, with -> arrows where a selection was changed) and dependencyInsight (why one module was selected). The settings, properties and build files of this section are this page’s own lab setup, not taken from a manual.

Forcing versions

force makes a module resolve to the given version in a configuration, regardless of what is declared or requested.

Kotlin DSL (app/build.gradle.kts)
configurations.configureEach {
    resolutionStrategy.force("org.apache.commons:commons-lang3:3.14.0")
}
Groovy DSL (app/build.gradle)
configurations.configureEach {
    resolutionStrategy.force 'org.apache.commons:commons-lang3:3.14.0'
}
$ ./gradlew -q :app:dependencyInsight --configuration runtimeClasspath --dependency commons-lang3
org.apache.commons:commons-lang3:3.14.0 (forced)
org.apache.commons:commons-lang3:3.12.0 -> 3.14.0
\--- org.apache.commons:commons-text:1.10.0
     \--- runtimeClasspath

Commons Text asked for 3.12.0; the report shows the module selected as 3.14.0 (forced). The user manual’s own example forces a transitive dependency only in compileClasspath; configureEach as above forces it in every configuration, which is usually what you want, because a forced compile classpath and an unforced runtime classpath would disagree.

A force also works downwards, which a plain (require) constraint cannot do. Forcing 3.11 below the requested 3.12.0 takes effect, while a constraint on 3.11 loses the conflict against 3.12.0:

$ ./gradlew -q :app:dependencyInsight --configuration runtimeClasspath --dependency commons-lang3
# with resolutionStrategy.force on commons-lang3:3.11
org.apache.commons:commons-lang3:3.11 (forced)
org.apache.commons:commons-lang3:3.12.0 -> 3.11
\--- org.apache.commons:commons-text:1.10.0
     \--- runtimeClasspath

$ ./gradlew -q :app:dependencyInsight --configuration runtimeClasspath --dependency commons-lang3
# with a constraint on commons-lang3:3.11 instead
org.apache.commons:commons-lang3:3.12.0
   Selection reasons:
      - By constraint: the feed needs at least 3.11
      - By conflict resolution: between versions 3.12.0 and 3.11
org.apache.commons:commons-lang3:3.12.0
\--- org.apache.commons:commons-text:1.10.0
     \--- runtimeClasspath
org.apache.commons:commons-lang3:3.11 -> 3.12.0
\--- runtimeClasspath

A force is not the only way down, though: a strictly rich version in a constraint (version { strictly("3.11") } with because("the feed must stay on 3.11"), in the Kotlin DSL build) also downgrades, and unlike a force it is published with a library’s metadata and shows its reason in the report:

$ ./gradlew -q :app:dependencyInsight --configuration runtimeClasspath --dependency commons-lang3
# with a strictly constraint on commons-lang3:3.11 instead
org.apache.commons:commons-lang3:3.11
...
   Selection reasons:
      - By ancestor
      - By constraint: the feed must stay on 3.11

org.apache.commons:commons-lang3:{strictly 3.11} -> 3.11
\--- runtimeClasspath

org.apache.commons:commons-lang3:3.12.0 -> 3.11
\--- org.apache.commons:commons-text:1.10.0
     \--- runtimeClasspath
...

Strict versions are described on Platforms, Constraints & Rich Versions.

Forcing is blunt. The manual warns that it can cause conflicts or unexpected behaviour when other dependencies rely on a different version, so it should be a conscious decision; the because text of a constraint, or the strictness of a rich version, leaves a record in the reports that a bare force does not.

Computing versions with eachDependency

eachDependency calls an action for every dependency as it is resolved, with a DependencyResolveDetails object that can redirect the version (useVersion) or the whole module (useTarget) and record a reason (because). Use it when the decision is a policy, not one coordinate.

Kotlin DSL (app/build.gradle.kts)
configurations.configureEach {
    resolutionStrategy.eachDependency {
        if (requested.group == "commons-codec") {
            useVersion("1.16.1")
            because("the feed pins Commons Codec to one audited release")
        }
    }
}
Groovy DSL (app/build.gradle)
configurations.configureEach {
    resolutionStrategy.eachDependency { DependencyResolveDetails details ->
        if (details.requested.group == 'commons-codec') {
            details.useVersion '1.16.1'
            details.because 'the feed pins Commons Codec to one audited release'
        }
    }
}
$ ./gradlew -q :app:dependencyInsight --configuration runtimeClasspath --dependency commons-codec
commons-codec:commons-codec:1.16.1
   Selection reasons:
      - Selected by rule: the feed pins Commons Codec to one audited release
commons-codec:commons-codec:1.11 -> 1.16.1
\--- org.apache.httpcomponents:httpclient:4.5.14
     \--- runtimeClasspath

The reason shows up in the report as "Selected by rule". The manual also shows the same hook implementing a custom versioning scheme, where developers write a placeholder version such as default and the rule resolves it from a corporate catalog; such a rule is normally shipped in a convention plugin (see Convention Plugins & Sharing Build Logic).

Dependency substitution

Substitution rules replace one selector with another before the version is chosen. A selector is either a module (module("group:name"), optionally with a version) or a project (project(":path")), so a substitution can go from module to module, module to project and project to module. Since Gradle 7 the rule is written substitute(a).using(b).because("..."); the older with method was deprecated in the 7.x series and removed in Gradle 8.0 (see the upgrade notes under References).

Module to module: a replacement library

Commons Logging is a logging facade that HttpClient pulls in. A common wish is to route it to SLF4J without touching HttpClient: SLF4J publishes jcl-over-slf4j, a drop-in for the Commons Logging API.

Kotlin DSL (app/build.gradle.kts)
configurations.configureEach {
    resolutionStrategy.dependencySubstitution {
        substitute(module("commons-logging:commons-logging"))
            .using(module("org.slf4j:jcl-over-slf4j:2.0.13"))
            .because("route Commons Logging calls to SLF4J")
    }
}
Groovy DSL (app/build.gradle)
configurations.configureEach {
    resolutionStrategy.dependencySubstitution {
        substitute module('commons-logging:commons-logging') using module('org.slf4j:jcl-over-slf4j:2.0.13') because 'route Commons Logging calls to SLF4J'
    }
}
$ ./gradlew -q :app:dependencies --configuration runtimeClasspath
------------------------------------------------------------
Project ':app'
------------------------------------------------------------
runtimeClasspath - Runtime classpath of source set 'main'.
+--- org.apache.httpcomponents:httpclient:4.5.14
|    +--- org.apache.httpcomponents:httpcore:4.4.16
|    +--- commons-logging:commons-logging:1.2 -> org.slf4j:jcl-over-slf4j:2.0.13
|    |    \--- org.slf4j:slf4j-api:2.0.13
|    \--- commons-codec:commons-codec:1.11
\--- org.apache.commons:commons-text:1.10.0
     \--- org.apache.commons:commons-lang3:3.12.0

$ ./gradlew -q :app:dependencyInsight --configuration runtimeClasspath --dependency jcl-over-slf4j
org.slf4j:jcl-over-slf4j:2.0.13 (selected by rule)
commons-logging:commons-logging:1.2 -> org.slf4j:jcl-over-slf4j:2.0.13
\--- org.apache.httpcomponents:httpclient:4.5.14
     \--- runtimeClasspath

The replacement brought its own dependency, slf4j-api. In this run the report marks the module "selected by rule" but does not print the text given to because, unlike the "Selected by rule" reason of the eachDependency example above. Running the application on this classpath still works, because the replacement implements the same API:

$ ./gradlew -q :app:run
Tide Feed Ready

Module to project: a local patched copy

Replacing an external module by a project of the same build is how you test a patch before the library releases it. The substituted project must be part of the build (it is in include(...)); the rule connects a module selector to it and Gradle adds the project’s tasks to the work that needs the configuration.

Kotlin DSL (app/build.gradle.kts)
configurations.configureEach {
    resolutionStrategy.dependencySubstitution {
        substitute(module("commons-codec:commons-codec"))
            .using(project(":commons-codec"))
            .because("the feed runs against our patched copy")
    }
}
Groovy DSL (app/build.gradle)
configurations.configureEach {
    resolutionStrategy.dependencySubstitution {
        substitute module('commons-codec:commons-codec') using project(':commons-codec') because 'the feed runs against our patched copy'
    }
}
$ ./gradlew -q :app:dependencies --configuration runtimeClasspath
------------------------------------------------------------
Project ':app'
------------------------------------------------------------
runtimeClasspath - Runtime classpath of source set 'main'.
+--- org.apache.httpcomponents:httpclient:4.5.14
|    +--- org.apache.httpcomponents:httpcore:4.4.16
|    +--- commons-logging:commons-logging:1.2
|    \--- commons-codec:commons-codec:1.11 -> project ':commons-codec'
\--- org.apache.commons:commons-text:1.10.0
     \--- org.apache.commons:commons-lang3:3.12.0

Because the substituted selector might be a project, Gradle has to know the graph before it fixes the task graph. The manual states that adding a substitution rule makes Gradle resolve the configuration during task graph construction instead of on first use. The effect is visible in a dry run of build: without the rule the commons-codec tasks are not part of the work, with the rule they are.

$ ./gradlew -q :app:build --dry-run          # without the rule
:app:compileJava SKIPPED
:app:processResources SKIPPED
:app:classes SKIPPED
:app:jar SKIPPED
:app:startScripts SKIPPED
:app:distTar SKIPPED
:app:distZip SKIPPED
:app:assemble SKIPPED
:app:compileTestJava SKIPPED
:app:processTestResources SKIPPED
:app:testClasses SKIPPED
:app:test SKIPPED
:app:check SKIPPED
:app:build SKIPPED

$ ./gradlew -q :app:build --dry-run          # with the rule
:commons-codec:compileJava SKIPPED
:app:compileJava SKIPPED
:app:processResources SKIPPED
:app:classes SKIPPED
:app:jar SKIPPED
:commons-codec:processResources SKIPPED
:commons-codec:classes SKIPPED
:commons-codec:jar SKIPPED
:app:startScripts SKIPPED
:app:distTar SKIPPED
:app:distZip SKIPPED
:app:assemble SKIPPED
:app:compileTestJava SKIPPED
:app:processTestResources SKIPPED
:app:testClasses SKIPPED
:app:test SKIPPED
:app:check SKIPPED
:app:build SKIPPED

The order of unrelated tasks in a dry run is not fixed by Gradle. The same module-to-project idea at the level of whole builds, with the substitution done automatically, is the composite build (see Composite Builds).

Conditional substitution

A rule can also look at each requested selector and decide. The manual’s recipe substitutes a module by a local project only when a project of that name exists and otherwise leaves the module alone, so the same build works with and without the fork checked out.

Kotlin DSL (app/build.gradle.kts)
configurations.configureEach {
    resolutionStrategy.dependencySubstitution.all {
        requested.let {
            if (it is ModuleComponentSelector && it.group == "commons-codec") {
                val local = findProject(":${it.module}")
                if (local != null) {
                    useTarget(local, "a local project with that name exists")
                }
            }
        }
    }
}
Groovy DSL (app/build.gradle)
configurations.configureEach {
    resolutionStrategy.dependencySubstitution.all { DependencySubstitution dependency ->
        if (dependency.requested instanceof ModuleComponentSelector && dependency.requested.group == 'commons-codec') {
            def local = findProject(":${dependency.requested.module}")
            if (local != null) {
                dependency.useTarget local, 'a local project with that name exists'
            }
        }
    }
}

In this build the project is called commons-codec, so the rule produced exactly the tree of the explicit substitution above (commons-codec:commons-codec:1.11 -> project ':commons-codec'), in both DSLs.

Project to module: a released artifact instead of a local build

The reverse direction speeds up a development cycle: a project that nobody is changing is replaced by a published version, which Gradle downloads instead of building. The module must carry a version. The app build declares the project dependency first.

Kotlin DSL (app/build.gradle.kts)
dependencies {
    implementation(project(":commons-codec"))
}

configurations.configureEach {
    resolutionStrategy.dependencySubstitution {
        substitute(project(":commons-codec"))
            .using(module("commons-codec:commons-codec:1.16.1"))
            .because("use the released artifact while the fork is parked")
    }
}
Groovy DSL (app/build.gradle)
dependencies {
    implementation project(':commons-codec')
}

configurations.configureEach {
    resolutionStrategy.dependencySubstitution {
        substitute project(':commons-codec') using module('commons-codec:commons-codec:1.16.1') because 'use the released artifact while the fork is parked'
    }
}
$ ./gradlew -q :app:dependencies --configuration runtimeClasspath
------------------------------------------------------------
Project ':app'
------------------------------------------------------------
runtimeClasspath - Runtime classpath of source set 'main'.
+--- org.apache.httpcomponents:httpclient:4.5.14
|    +--- org.apache.httpcomponents:httpcore:4.4.16
|    +--- commons-logging:commons-logging:1.2
|    \--- commons-codec:commons-codec:1.11 -> 1.16.1
+--- org.apache.commons:commons-text:1.10.0
|    \--- org.apache.commons:commons-lang3:3.12.0
\--- project ':commons-codec' -> commons-codec:commons-codec:1.16.1

The manual lists two further forms of substitution that this page does not run: variant-aware substitution, which targets a selector with attributes, capabilities or a classifier (see Variant-Aware Resolution & Module Metadata), and global substitution rules shared by included builds (useGlobalDependencySubstitutionRules, covered on the composite builds page).

Excluding through rules

exclude removes a module from the resolved graph. On a configuration it applies to every dependency declared in it, direct or transitive.

Kotlin DSL (app/build.gradle.kts)
dependencies {
    implementation("commons-beanutils:commons-beanutils:1.9.4")
}

configurations.configureEach {
    exclude(group = "commons-logging", module = "commons-logging")
}
Groovy DSL (app/build.gradle)
dependencies {
    implementation 'commons-beanutils:commons-beanutils:1.9.4'
}

configurations.configureEach {
    exclude group: 'commons-logging', module: 'commons-logging'
}
$ ./gradlew -q :app:dependencies --configuration runtimeClasspath
------------------------------------------------------------
Project ':app'
------------------------------------------------------------
runtimeClasspath - Runtime classpath of source set 'main'.
+--- org.apache.httpcomponents:httpclient:4.5.14
|    +--- org.apache.httpcomponents:httpcore:4.4.16
|    \--- commons-codec:commons-codec:1.11
+--- org.apache.commons:commons-text:1.10.0
|    \--- org.apache.commons:commons-lang3:3.12.0
\--- commons-beanutils:commons-beanutils:1.9.4
     \--- commons-collections:commons-collections:3.2.2

Commons Logging is gone from both paths that led to it. Gradle evaluates the whole graph, which is different from Maven’s per-path exclusions: an exclusion on one dependency only takes effect if every path to the module agrees. Excluding it on httpclient alone, while commons-beanutils also brings it in, leaves the module in place:

Kotlin DSL (app/build.gradle.kts)
dependencies {
    implementation("commons-beanutils:commons-beanutils:1.9.4")
    implementation("org.apache.httpcomponents:httpclient:4.5.14") {
        exclude(group = "commons-logging", module = "commons-logging")
    }
}
Groovy DSL (app/build.gradle)
dependencies {
    implementation 'commons-beanutils:commons-beanutils:1.9.4'
    implementation('org.apache.httpcomponents:httpclient:4.5.14') {
        exclude group: 'commons-logging', module: 'commons-logging'
    }
}
$ ./gradlew -q :app:dependencies --configuration runtimeClasspath
------------------------------------------------------------
Project ':app'
------------------------------------------------------------
runtimeClasspath - Runtime classpath of source set 'main'.
+--- org.apache.httpcomponents:httpclient:4.5.14
|    +--- org.apache.httpcomponents:httpcore:4.4.16
|    +--- commons-logging:commons-logging:1.2
|    \--- commons-codec:commons-codec:1.11
+--- org.apache.commons:commons-text:1.10.0
|    \--- org.apache.commons:commons-lang3:3.12.0
\--- commons-beanutils:commons-beanutils:1.9.4
     +--- commons-logging:commons-logging:1.2
     \--- commons-collections:commons-collections:3.2.2

An exclusion is also unconditional: if code that remains on the classpath needs the class, the failure comes at run time as a NoClassDefFoundError. The manual therefore suggests an exclusion only after a conscious, tested decision, and lists better tools: a constraint to move versions, a component metadata rule (next sections) to remove a dependency that the metadata declares by mistake, and capabilities for mutually exclusive libraries. Since Gradle 7.3 exclusions are also checked against the result of a substitution (upgrade notes under References).

Module replacement and component selection

Two more rules shape conflict resolution and version selection.

Module replacement tells Gradle that one module has been replaced by another. When a library is renamed (the manual’s example is google-collections, which became Guava), two coordinates that name the same classes are not a version conflict for Gradle. A replacedBy rule makes it one: if both appear, the replacement wins.

Kotlin DSL (app/build.gradle.kts)
dependencies {
    implementation("com.google.guava:guava:33.2.1-jre")
    implementation("com.google.collections:google-collections:1.0")
    modules {
        module("com.google.collections:google-collections") {
            replacedBy("com.google.guava:guava", "google-collections is now part of Guava")
        }
    }
}
Groovy DSL (app/build.gradle)
dependencies {
    implementation 'com.google.guava:guava:33.2.1-jre'
    implementation 'com.google.collections:google-collections:1.0'
    modules {
        module('com.google.collections:google-collections') {
            replacedBy('com.google.guava:guava', 'google-collections is now part of Guava')
        }
    }
}
$ ./gradlew -q :app:dependencyInsight --configuration runtimeClasspath --dependency google-collections
com.google.guava:guava:33.2.1-jre
   Selection reasons:
      - Selected by rule: google-collections is now part of Guava
com.google.collections:google-collections:1.0 -> com.google.guava:guava:33.2.1-jre
\--- runtimeClasspath

As the manual notes, the replacement only happens when there is a conflict; if google-collections is the only one of the two in the graph it stays, and one module cannot be replaced by several modules (several can be replaced by one).

Component selection rules decide which of the available versions a selector may pick, and are mainly useful with dynamic versions. A rule can reject a candidate, and Gradle then goes on to the next highest version that no rule rejects. Commons Lang 3 had 3.21.0 as its latest release on the day of this run; with the dynamic selector 3.+ and a rule that rejects it, Gradle picks the next one:

Kotlin DSL (app/build.gradle.kts)
dependencies {
    implementation("org.apache.commons:commons-lang3:3.+")
}

configurations.configureEach {
    resolutionStrategy.componentSelection {
        withModule("org.apache.commons:commons-lang3") {
            if (candidate.version == "3.21.0") {
                reject("3.21.0 is not approved for the feed")
            }
        }
    }
}
Groovy DSL (app/build.gradle)
dependencies {
    implementation 'org.apache.commons:commons-lang3:3.+'
}

configurations.configureEach {
    resolutionStrategy.componentSelection {
        withModule('org.apache.commons:commons-lang3') { ComponentSelection selection ->
            if (selection.candidate.version == '3.21.0') {
                selection.reject('3.21.0 is not approved for the feed')
            }
        }
    }
}
$ ./gradlew -q :app:dependencyInsight --configuration runtimeClasspath --dependency commons-lang3
org.apache.commons:commons-lang3:3.20.0
   Selection reasons:
      - Rejection: 3.21.0 by rule because 3.21.0 is not approved for the feed
      - By conflict resolution: between versions 3.12.0 and 3.20.0
org.apache.commons:commons-lang3:3.12.0 -> 3.20.0
\--- org.apache.commons:commons-text:1.10.0
     \--- runtimeClasspath
org.apache.commons:commons-lang3:3.+ -> 3.20.0
\--- runtimeClasspath

Gradle caches dynamic versions between builds (for 24 hours by default, according to the dependency caching chapter), and a result that changes by the day is the opposite of a reproducible build; pin them with dependency locking (see Dependency Locking, Verification & Caching). The version 3.21.0 is the newest at the time of writing, so rerunning this example later may select a different version.

Component metadata rules

The rules above change what your build does with a graph. A component metadata rule changes the graph at the source: it edits the metadata of a module (its dependencies, constraints, variants and capabilities) after Gradle has read it from the repository and before it is used. That is the way to repair a POM that is wrong.

HttpClient 4.5.14 declares Commons Logging, and assume for the sake of the exercise that the feed never needs it. The rule below removes every dependency on the commons-logging group from all variants of that one module. It is a class, as the manual recommends, and annotated with @CacheableRule so that Gradle can cache its result instead of running it on every resolution.

Kotlin DSL (app/build.gradle.kts)
@CacheableRule
abstract class DropCommonsLogging : ComponentMetadataRule {
    override fun execute(context: ComponentMetadataContext) {
        context.details.allVariants {
            withDependencies {
                removeAll { it.group == "commons-logging" }
            }
        }
    }
}

dependencies {
    components {
        withModule<DropCommonsLogging>("org.apache.httpcomponents:httpclient")
    }
}
Groovy DSL (app/build.gradle)
@CacheableRule
abstract class DropCommonsLogging implements ComponentMetadataRule {
    void execute(ComponentMetadataContext context) {
        context.details.allVariants {
            withDependencies {
                removeAll { it.group == 'commons-logging' }
            }
        }
    }
}

dependencies {
    components {
        withModule('org.apache.httpcomponents:httpclient', DropCommonsLogging)
    }
}
$ ./gradlew -q :app:dependencies --configuration runtimeClasspath
------------------------------------------------------------
Project ':app'
------------------------------------------------------------
runtimeClasspath - Runtime classpath of source set 'main'.
+--- org.apache.httpcomponents:httpclient:4.5.14
|    +--- org.apache.httpcomponents:httpcore:4.4.16
|    \--- commons-codec:commons-codec:1.11
\--- org.apache.commons:commons-text:1.10.0
     \--- org.apache.commons:commons-lang3:3.12.0

Compared with an exclusion, this corrects the library’s description once and for everybody in the build: no path can bring the module back, and the wrong dependency is not in the graph for any configuration. Inside withDependencies you have the full list of the variant’s dependencies to inspect and change, and can add one with add(notation, action); withDependencyConstraints does the same for constraints. The same API adds variants and capabilities for libraries that publish only a POM (the manual’s example splits the optional features of Jaxen into variants), which belongs to the variant-aware page. Rules are not published with your library, so a library that must not expose a dependency to its consumers still needs an explicit exclude or a corrected POM.

A rule may be written inline as an Action instead of a class, which is convenient for experiments, but the manual advises against it for anything permanent because an inline rule cannot be cached. Rule classes get their parameters through params(...), which must be serializable, and can have services such as ObjectFactory injected.

Declaring rules once in settings

Component metadata rules can also be declared in settings.gradle(.kts) for the whole build, which the manual marks as an incubating feature. Where they are declared decides who wins when both places define rules: rulesMode is a choice between PREFER_PROJECT (the default), PREFER_SETTINGS and FAIL_ON_PROJECT_RULES, the counterpart of repositoriesMode for repositories.

Kotlin DSL (settings.gradle.kts)
@CacheableRule
abstract class DropCommonsLogging : ComponentMetadataRule {
    override fun execute(context: ComponentMetadataContext) {
        context.details.allVariants {
            withDependencies {
                removeAll { it.group == "commons-logging" }
            }
        }
    }
}

dependencyResolutionManagement {
    rulesMode = RulesMode.FAIL_ON_PROJECT_RULES
    components {
        withModule<DropCommonsLogging>("org.apache.httpcomponents:httpclient")
    }
}
Groovy DSL (settings.gradle)
@CacheableRule
abstract class DropCommonsLogging implements ComponentMetadataRule {
    void execute(ComponentMetadataContext context) {
        context.details.allVariants {
            withDependencies {
                removeAll { it.group == 'commons-logging' }
            }
        }
    }
}

dependencyResolutionManagement {
    rulesMode = RulesMode.FAIL_ON_PROJECT_RULES
    components {
        withModule('org.apache.httpcomponents:httpclient', DropCommonsLogging)
    }
}

(The two listings show only the added part of the settings file; repositories and include are as in the scenario.) With the rule in settings and no rule in the project, the starting app build resolved to the same tree as above without commons-logging, in both DSLs, with the configuration cache enabled. With the project rule from the previous section added on top, FAIL_ON_PROJECT_RULES made the build fail:

$ ./gradlew -q :app:dependencies --configuration runtimeClasspath
FAILURE: Build failed with an exception.
* Where:
Build file '/work/tidepool-feed/app/build.gradle.kts' line: 30
* What went wrong:
Build was configured to prefer settings component metadata rules over project rules but rule 'Build_gradle$DropCommonsLogging' was added by build file 'app/build.gradle.kts'

The Groovy build failed the same way ("A problem occurred evaluating project ':app'." at line 27 of app/build.gradle). The wording says "prefer settings" even though the mode that was set is the failing one; that is what Gradle 9.8.1 printed. The PREFER_SETTINGS mode, which was not run here, gives a warning instead of a failure when a project declares rules.

Choosing between rules and constraints

All of these can make a build resolve a module at a version you want. They differ in how visible, how general and how shareable the decision is.

Goal Prefer Why

Require at least a version, or stay inside a range

A constraint or a rich version (see Platforms, Constraints & Rich Versions)

Published in the module metadata, so consumers of a library get the same rule; recorded with its reason in the reports; takes part in normal conflict resolution.

Go below what a dependency asks for, or override everything

A strictly constraint, or force

A plain (require) constraint cannot lower a version; a strictly one can and is published with a library, while a force is local to the build and not published.

Replace a library by another, or by a local project, or the reverse

dependencySubstitution

The only rule that changes the identity of a module; the tool for forks and for module renames not covered by capabilities.

Choose versions by a policy

eachDependency

One action sees every dependency, so a catalog or an approved-version list is a few lines.

A library that was renamed and may appear twice

replacedBy, or a capability

Declares the relationship once for the whole build.

A broken dependency in somebody’s POM

A component metadata rule

Edits the cause once, instead of excluding the symptom in every configuration.

A dependency the code does not use

An exclude, tested

Removes the module for good, but risks a failure at run time; keep it narrow.

Skip one broken release of a dynamic version

A component selection rule

Leaves the rest of the range available.

Whatever you choose, record a reason (because) and a place: rules scattered across build scripts are hard to find. In a multi-project build put them in a convention plugin or in settings, as covered in Convention Plugins & Sharing Build Logic.

Configuration cache

Resolution rules run while Gradle resolves configurations, and the results go into the configuration cache entry, so rules as such are compatible. Every rule on this page was run with --configuration-cache through the dependencies report and each stored an entry without problems, in both DSLs.

What does matter is how a task uses a resolved configuration. A task must not reach for configurations at execution time; hand it the resolved file collection as an input instead. This task prints the substituted classpath of the module-to-project example, and was run twice with --configuration-cache:

Kotlin DSL (app/build.gradle.kts)
tasks.register("showClasspath") {
    val classpath = files(configurations.runtimeClasspath)
    inputs.files(classpath)
    doLast {
        classpath.files.forEach { println(it.name) }
    }
}
Groovy DSL (app/build.gradle)
tasks.register('showClasspath') {
    def classpath = files(configurations.runtimeClasspath)
    inputs.files(classpath)
    doLast {
        classpath.files.each { println it.name }
    }
}
$ ./gradlew --console=plain --configuration-cache :app:showClasspath    # first run
Calculating task graph as configuration cache cannot be reused because file 'app/build.gradle.kts' has changed.
...
Configuration cache entry stored.

$ ./gradlew --console=plain --configuration-cache :app:showClasspath    # second run
Reusing configuration cache.
> Task :commons-codec:processResources NO-SOURCE
> Task :commons-codec:compileJava UP-TO-DATE
> Task :commons-codec:classes UP-TO-DATE
> Task :commons-codec:jar UP-TO-DATE
> Task :app:showClasspath
httpclient-4.5.14.jar
commons-text-1.10.0.jar
httpcore-4.4.16.jar
commons-logging-1.2.jar
commons-codec.jar
commons-lang3-3.12.0.jar

BUILD SUCCESSFUL in 311ms
3 actionable tasks: 1 executed, 2 up-to-date
Configuration cache entry reused.

The first listing shows only the lines of the first run that concern the cache (the ellipsis stands for the rest of the output) and the Groovy build printed the same with app/build.gradle. Note that commons-codec.jar is the output of the local project, not the downloaded commons-codec-1.11.jar. The timing differs from run to run.

Resolving the configuration inside doLast, for example by calling configurations.runtimeClasspath.get().resolve() there, is the pattern the configuration cache documentation warns against, because the task would need the live Configuration at execution time; that variant was not run here. More on this on Configuration Cache & Isolated Projects.

Gradle 8.14 and older builds

Gradle 8.14.x was not available here, so nothing below was run.

  • force, eachDependency, dependencySubstitution with using, componentSelection, exclude, replacedBy and component metadata rules are the same APIs in 8.14; a search of the Gradle 9.0 upgrade guide for these API names found no entry about them.

  • substitute(...).with(...) was removed in Gradle 8.0; a build written for Gradle 7 or earlier needs using.

  • Component metadata rules in settings are marked incubating in the current user manual; whether the 8.14 documentation already shows them was not checked here.

  • Mutating a configuration after it has been observed (its dependencies, constraints, excludes, hierarchy or resolution strategy) was deprecated in 8.8 and is an error since Gradle 9.0. The one case the upgrade notes still permit, but do not recommend, is changing the resolutionStrategy inside a beforeResolve hook. Put rules where the configuration is created. Source: Upgrading within Gradle 8.x, Deprecate mutating configuration after observation

Details of how the DSLs and APIs evolved between majors are on Upgrading Gradle.

The same job in Maven and Ant with Ivy

Maven has no rule engine. Its answers are static: dependencyManagement pins versions of transitive dependencies for the whole module tree (see Maven Dependency Management & BOMs), exclusions remove a dependency per path (see Maven Dependencies, Scopes & Mediation), and a relocation element in a POM redirects a renamed artifact (see Installing Third-Party JARs & Relocation). Substituting a module by a local project is what the reactor does for modules of the same build, and a fork has to be installed in the local repository. Ivy, used from Ant, has override and conflict managers and per-dependency exclude elements in its module descriptor (see Ivy: Conflicts & Versions). The cross-tool picture is in Dependency Management Fundamentals.