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 |
|---|---|---|
|
The version of a module in one configuration, whatever anybody requests. |
Pin a transitive dependency up or down. |
|
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. |
|
A selector (module or project) into another selector (module or project). |
Swap a library for a fork, a local project or a replacement library. |
|
Removes a module from the graph of a configuration. |
Drop a dependency that the code never touches. |
|
Which candidate versions a selector may pick. |
Reject a broken release for a dynamic version. |
|
Declares that one module is replaced by another for conflict resolution. |
Renamed libraries such as google-collections and Guava. |
|
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.
settings.gradle.kts)rootProject.name = "tidepool-feed"
dependencyResolutionManagement {
repositoriesMode = RepositoriesMode.FAIL_ON_PROJECT_REPOS
repositories {
mavenCentral()
}
}
include("app", "commons-codec")
settings.gradle)rootProject.name = 'tidepool-feed'
dependencyResolutionManagement {
repositoriesMode = RepositoriesMode.FAIL_ON_PROJECT_REPOS
repositories {
mavenCentral()
}
}
include 'app', 'commons-codec'
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")
}
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.
app/build.gradle.kts)configurations.configureEach {
resolutionStrategy.force("org.apache.commons:commons-lang3:3.14.0")
}
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.
Source: Gradle User Manual: Forcing Dependency Versions and Declaring Versions and Ranges, strict versions in constraints
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.
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")
}
}
}
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.
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")
}
}
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.
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")
}
}
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.
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")
}
}
}
}
}
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.
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")
}
}
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.
app/build.gradle.kts)dependencies {
implementation("commons-beanutils:commons-beanutils:1.9.4")
}
configurations.configureEach {
exclude(group = "commons-logging", module = "commons-logging")
}
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:
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")
}
}
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.
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")
}
}
}
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:
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")
}
}
}
}
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.
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")
}
}
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.
Source: Gradle User Manual: Modifying Dependency Metadata, writing a component metadata rule and fixing wrong dependency details
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.
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")
}
}
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 |
A plain ( |
Replace a library by another, or by a local project, or the reverse |
|
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 |
|
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 |
|
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 |
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:
app/build.gradle.kts)tasks.register("showClasspath") {
val classpath = files(configurations.runtimeClasspath)
inputs.files(classpath)
doLast {
classpath.files.forEach { println(it.name) }
}
}
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,dependencySubstitutionwithusing,componentSelection,exclude,replacedByand 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 needsusing. -
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
resolutionStrategyinside abeforeResolvehook. 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.