Gradle provides several mechanisms to directly influence the behavior of the dependency resolution engine.

Unlike dependency constraints or component metadata rules, which serve as inputs to the resolution process, these mechanisms allow you to inject rules directly into the resolution engine. Because of their direct impact, they can be considered brute-force solutions that may mask underlying issues, such as the introduction of new dependencies.

It’s generally advisable to resort to resolution rules only when other approaches are insufficient.

If you’re developing a library, it’s best to use dependency constraints, as they are shared with your consumers.

Here are the key resolution strategies in Gradle:

# Strategy Info

1

Forcing Dependency Versions

Force a specific version of a dependency.

2

Module Replacement

Substitute one module for another with an explanation.

3

Dependency Substitution

Substitute dependencies dynamically.

4

Component Selection Rules

Control which versions of a module are allowed. Reject specific versions that are known to be broken or undesirable.

5

Default Dependencies

Automatically add dependencies to a configuration when no dependencies are explicitly declared.

6

Excluding Transitive Dependencies

Exclude transitive dependencies that you don’t want to be included in the dependency graph.

7

Force Failed Resolution Strategies

Force builds to fail when certain conditions occur during resolution.

8

Disabling Transitive Dependencies

Dependencies are transitive by default, but you can disable this behavior for individual dependencies.

9

Dependency Resolve Rules and Other Conditionals

Transform or filter dependencies directly as they are resolved and other corner case scenarios.

1. Forcing Dependency Versions

You can enforce a specific version of a dependency, regardless of what versions might be requested or resolved by other parts of the build script.

This is useful for ensuring consistency and avoiding conflicts due to different versions of the same dependency being used.

build.gradle.kts
configurations {
    "compileClasspath" {
        resolutionStrategy.force("commons-codec:commons-codec:1.9")
    }
}

dependencies {
    implementation("org.apache.httpcomponents:httpclient:4.5.4")
}
build.gradle
configurations {
    compileClasspath {
        resolutionStrategy.force 'commons-codec:commons-codec:1.9'
    }
}

dependencies {
    implementation 'org.apache.httpcomponents:httpclient:4.5.4'
}

2. Module Replacement

While it’s generally better to manage module conflicts using capabilities, there are scenarios—especially when working with older versions of Gradle-that require a different approach. In these cases, module replacement rules offer a solution by allowing you to specify that a legacy library has been replaced by a newer one.

Module replacement rules allow you to declare that a legacy library has been replaced by a newer one. For instance, the migration from google-collections to guava involved renaming the module from com.google.collections:google-collections to com.google.guava:guava. Such changes impact conflict resolution because Gradle doesn’t treat them as version conflicts due to different module coordinates.

Consider a scenario where both libraries appear in the dependency graph. Your project depends on guava, but a transitive dependency pulls in google-collections. This can cause runtime errors since Gradle won’t automatically resolve this as a conflict. Common solutions include:

  • Declaring an exclusion rule to avoid google-collections.

  • Avoiding dependencies that pull in legacy libraries.

  • Upgrading dependencies that no longer use google-collections.

  • Downgrading to google-collections (not recommended).

  • Assigning capabilities so google-collections and guava are mutually exclusive.

These methods can be insufficient for large-scale projects. By declaring module replacements, you can address this issue globally across projects, allowing organizations to handle such conflicts holistically.

build.gradle.kts
dependencies {
    modules {
        module("com.google.collections:google-collections") {
            replacedBy("com.google.guava:guava", "google-collections is now part of Guava")
        }
    }
}
build.gradle
dependencies {
    modules {
        module("com.google.collections:google-collections") {
            replacedBy("com.google.guava:guava", "google-collections is now part of Guava")
        }
    }
}

Once declared, Gradle treats any version of guava as superior to google-collections during conflict resolution, ensuring only guava appears in the classpath. However, if google-collections is the only module present, it won’t be automatically replaced unless there’s a conflict.

For more examples, refer to the DSL reference for ComponentMetadataHandler.

Gradle does not currently support replacing a module with multiple modules, but multiple modules can be replaced by a single module.

3. Dependency Substitution

Dependency substitution rules allow for replacing project and module dependencies with specified alternatives, making them interchangeable. While similar to dependency resolve rules, they offer more flexibility by enabling substitution between project and module dependencies.

However, adding a dependency substitution rule affects the timing of configuration resolution. Instead of resolving on first use, the configuration is resolved during task graph construction, which can cause issues if the configuration is modified later or depends on modules published during task execution.

Explanation:

  • A configuration can serve as input to a task and include project dependencies when resolved.

  • If a project dependency is an input to a task (via a configuration), then tasks to build those artifacts are added as dependencies.

  • To determine project dependencies that are inputs to a task, Gradle must resolve the configuration inputs.

  • Because the Gradle task graph is fixed once task execution has commenced, Gradle needs to perform this resolution prior to executing any tasks.

Without substitution rules, Gradle assumes that external module dependencies don’t reference project dependencies, simplifying dependency traversal. With substitution rules, this assumption no longer holds, so Gradle must fully resolve the configuration to determine project dependencies.

Substituting an external module dependency with a project dependency

Dependency substitution can be used to replace an external module with a locally developed project, which is helpful when testing a patched or unreleased version of a module.

The external module can be replaced whether a version is specified:

build.gradle.kts
configurations.all {
    resolutionStrategy.dependencySubstitution {
        substitute(module("org.utils:api"))
            .using(project(":api")).because("we work with the unreleased development version")
        substitute(module("org.utils:util:2.5")).using(project(":util"))
    }
}
build.gradle
configurations.all {
    resolutionStrategy.dependencySubstitution {
        substitute module("org.utils:api") using project(":api") because "we work with the unreleased development version"
        substitute module("org.utils:util:2.5") using project(":util")
    }
}
  • Substituted projects must be part of the multi-project build (included via settings.gradle).

  • The substitution replaces the module dependency with the project dependency and sets up task dependencies, but doesn’t automatically include the project in the build.

Substituting a project dependency with a module replacement

You can also use substitution rules to replace a project dependency with an external module in a multi-project build.

This technique can accelerate development by allowing certain dependencies to be downloaded from a repository instead of being built locally:

build.gradle.kts
configurations.all {
    resolutionStrategy.dependencySubstitution {
        substitute(project(":api"))
            .using(module("org.utils:api:1.3")).because("we use a stable version of org.utils:api")
    }
}
build.gradle
configurations.all {
    resolutionStrategy.dependencySubstitution {
        substitute project(":api") using module("org.utils:api:1.3") because "we use a stable version of org.utils:api"
    }
}
  • The substituted module must include a version.

  • Even after substitution, the project remains part of the multi-project build, but tasks to build it won’t be executed when resolving the configuration.

Conditionally substituting a dependency

You can conditionally substitute a module dependency with a local project in a multi-project build using dependency substitution rules.

This is particularly useful when you want to use a locally developed version of a dependency if it exists, otherwise fall back to the external module:

build.gradle.kts
configurations.all {
    resolutionStrategy.dependencySubstitution.all {
        requested.let {
            if (it is ModuleComponentSelector && it.group == "org.example") {
                val targetProject = findProject(":${it.module}")
                if (targetProject != null) {
                    useTarget(targetProject)
                }
            }
        }
    }
}
build.gradle
configurations.all {
    resolutionStrategy.dependencySubstitution.all { DependencySubstitution dependency ->
        if (dependency.requested instanceof ModuleComponentSelector && dependency.requested.group == "org.example") {
            def targetProject = findProject(":${dependency.requested.module}")
            if (targetProject != null) {
                dependency.useTarget targetProject
            }
        }
    }
}
  • The substitution only occurs if a local project matching the dependency name is found.

  • The local project must already be included in the multi-project build (via settings.gradle).

Substituting a dependency with another variant

You can substitute a dependency with another variant, such as switching between a platform dependency and a regular library dependency.

This is useful when your build process needs to change the type of dependency based on specific conditions:

configurations.all {
    resolutionStrategy.dependencySubstitution {
        all {
            if (requested is ModuleComponentSelector && requested.group == "org.example" && requested.version == "1.0") {
                useTarget(module("org.example:library:1.0")).because("Switching from platform to library variant")
            }
        }
    }
}
  • The substitution is based on the requested dependency’s attributes (like group and version).

  • This approach allows you to switch from a platform component to a library or vice versa.

  • It uses Gradle’s variant-aware engine to ensure the correct variant is selected based on the configuration and consumer attributes.

This flexibility is often required when working with complex dependency graphs where different component types (platforms, libraries) need to be swapped dynamically.

Substituting a dependency with attributes

Substituting a dependency based on attributes allows you to override the default selection of a component by targeting specific attributes (like platform vs. regular library).

This technique is useful for managing platform and library dependencies in complex builds, particularly when you want to consume a regular library but the platform dependency was incorrectly declared:

lib/build.gradle.kts
dependencies {
    // This is a platform dependency but you want the library
    implementation(platform("com.google.guava:guava:28.2-jre"))
}
lib/build.gradle
dependencies {
    // This is a platform dependency but you want the library
    implementation platform('com.google.guava:guava:28.2-jre')
}

In this example, the substitution rule targets the platform version of com.google.guava:guava and replaces it with the regular library version:

consumer/build.gradle.kts
configurations.all {
    resolutionStrategy.dependencySubstitution {
        substitute(platform(module("com.google.guava:guava:28.2-jre")))
            .using(module("com.google.guava:guava:28.2-jre"))
    }
}
consumer/build.gradle
configurations.all {
    resolutionStrategy.dependencySubstitution {
        substitute(platform(module('com.google.guava:guava:28.2-jre'))).
            using module('com.google.guava:guava:28.2-jre')
    }
}

Without the platform keyword, the substitution would not specifically target the platform dependency.

The following rule performs the same substitution but uses the more granular variant notation, allowing for customization of the dependency’s attributes:

consumer/build.gradle.kts
configurations.all {
    resolutionStrategy.dependencySubstitution {
        substitute(variant(module("com.google.guava:guava:28.2-jre")) {
            attributes {
                attribute(Category.CATEGORY_ATTRIBUTE, objects.named(Category.REGULAR_PLATFORM))
            }
        }).using(module("com.google.guava:guava:28.2-jre"))
    }
}
consumer/build.gradle
configurations.all {
    resolutionStrategy.dependencySubstitution {
        substitute variant(module('com.google.guava:guava:28.2-jre')) {
            attributes {
                attribute(Category.CATEGORY_ATTRIBUTE, objects.named(Category, Category.REGULAR_PLATFORM))
            }
        } using module('com.google.guava:guava:28.2-jre')
    }
}

By using attribute-based substitution, you can precisely control which dependencies are replaced, ensuring Gradle resolves the correct versions and variants in your build.

Refer to the DependencySubstitutions API for a complete reference.

In composite builds, the rule that you have to match the exact requested dependency attributes is not applied. When using composites, Gradle will automatically match the requested attributes. In other words, it is implicit that if you include another build, you are substituting all variants of the substituted module with an equivalent variant in the included build.

Substituting a dependency with a dependency with capabilities

You can substitute a dependency with a different variant that includes specific capabilities. Capabilities allow you to specify that a particular variant of a dependency offers a set of related features or functionality, such as test fixtures.

This example substitutes a regular dependency with its test fixtures using a capability:

build.gradle.kts
configurations.testCompileClasspath {
    resolutionStrategy.dependencySubstitution {
        substitute(module("com.acme:lib:1.0")).using(variant(module("com.acme:lib:1.0")) {
            capabilities {
                requireCapability("com.acme:lib-test-fixtures")
            }
        })
    }
}
build.gradle
configurations.testCompileClasspath {
    resolutionStrategy.dependencySubstitution {
        substitute(module('com.acme:lib:1.0'))
            .using variant(module('com.acme:lib:1.0')) {
            capabilities {
                requireCapability('com.acme:lib-test-fixtures')
            }
        }
    }
}

Here, we substitute the regular com.acme:lib:1.0 dependency with its lib-test-fixtures variant. The requireCapability function specifies that the new variant must have the com.acme:lib-test-fixtures capability, ensuring the right version of the dependency is selected for testing purposes.

Capabilities within the substitution rule are used to precisely match dependencies, and Gradle only substitutes dependencies that match the required capabilities.

Refer to the DependencySubstitutions API for a complete reference of the variant substitution API.

Substituting a dependency with a classifier or artifact

You can substitute dependencies that have a classifier with ones that don’t or vice versa. Classifiers are often used to represent different versions of the same artifact, such as platform-specific builds or dependencies with different APIs. Although Gradle discourages the use of classifiers, it provides a way to handle substitutions for cases where classifiers are still in use.

Consider the following setup:

consumer/build.gradle.kts
dependencies {
    implementation("com.google.guava:guava:28.2-jre")
    implementation("co.paralleluniverse:quasar-core:0.8.0")
    implementation(project(":lib"))
}
consumer/build.gradle
dependencies {
    implementation 'com.google.guava:guava:28.2-jre'
    implementation 'co.paralleluniverse:quasar-core:0.8.0'
    implementation project(':lib')
}

In the example above, the first level dependency on quasar makes us think that Gradle would resolve quasar-core-0.8.0.jar but it’s not the case.

The build fails with this message:

Execution failed for task ':consumer:resolve'.
> Could not resolve all files for configuration ':consumer:runtimeClasspath'.
   > Could not find quasar-core-0.8.0-jdk8.jar (co.paralleluniverse:quasar-core:0.8.0).
     Searched in the following locations:
         https://repo.maven.apache.org/maven2/co/paralleluniverse/quasar-core/0.8.0/quasar-core-0.8.0-jdk8.jar

That’s because there’s a dependency on another project, lib, which itself depends on a different version of quasar-core:

lib/build.gradle.kts
dependencies {
    implementation("co.paralleluniverse:quasar-core:0.7.10:jdk8")
}
lib/build.gradle
dependencies {
    implementation "co.paralleluniverse:quasar-core:0.7.10:jdk8"
}
  • The consumer depends on quasar-core:0.8.0 without a classifier.

  • The lib project depends on quasar-core:0.7.10 with the jdk8 classifier.

  • Gradle’s conflict resolution selects the higher version (0.8.0), but quasar-core:0.8.0 doesn’t have the jdk8 classifier, leading to a resolution error.

To resolve this conflict, you can instruct Gradle to ignore classifiers when resolving quasar-core dependencies:

consumer/build.gradle.kts
configurations.all {
    resolutionStrategy.dependencySubstitution {
        substitute(module("co.paralleluniverse:quasar-core"))
            .using(module("co.paralleluniverse:quasar-core:0.8.0"))
            .withoutClassifier()
    }
}
consumer/build.gradle
configurations.all {
    resolutionStrategy.dependencySubstitution {
        substitute module('co.paralleluniverse:quasar-core') using module('co.paralleluniverse:quasar-core:0.8.0') withoutClassifier()
    }
}

This rule effectively replaces any dependency on quasar-core found in the graph with a dependency without classifier.

If you need to substitute with a specific classifier or artifact, you can specify the classifier or artifact details in the substitution rule.

For more detailed information, refer to:

4. Component Selection Rules

Component selection rules may influence which component instance should be selected when multiple versions are available that match a version selector. Rules are applied against every available version and allow the version to be explicitly rejected.

This allows Gradle to ignore any component instance that does not satisfy conditions set by the rule. Examples include:

  • For a dynamic version like 1.+ certain versions may be explicitly rejected from selection.

  • For a static version like 1.4 an instance may be rejected based on extra component metadata such as the Ivy branch attribute, allowing an instance from a subsequent repository to be used.

Rules are configured via the ComponentSelectionRules object. Each rule configured will be called with a ComponentSelection object as an argument that contains information about the candidate version being considered. Calling ComponentSelection.reject(java.lang.String) causes the given candidate version to be explicitly rejected, in which case the candidate will not be considered for the selector.

The following example shows a rule that disallows a particular version of a module but allows the dynamic version to choose the next best candidate:

build.gradle.kts
configurations {
    implementation {
        resolutionStrategy {
            componentSelection {
                // Accept the highest version matching the requested version that isn't '1.5'
                all {
                    if (candidate.group == "org.sample" && candidate.module == "api" && candidate.version == "1.5") {
                        reject("version 1.5 is broken for 'org.sample:api'")
                    }
                }
            }
        }
    }
}

dependencies {
    implementation("org.sample:api:1.+")
}
build.gradle
configurations {
    implementation {
        resolutionStrategy {
            componentSelection {
                // Accept the highest version matching the requested version that isn't '1.5'
                all { ComponentSelection selection ->
                    if (selection.candidate.group == 'org.sample' && selection.candidate.module == 'api' && selection.candidate.version == '1.5') {
                        selection.reject("version 1.5 is broken for 'org.sample:api'")
                    }
                }
            }
        }
    }
}

dependencies {
    implementation 'org.sample:api:1.+'
}

Note that version selection is applied starting with the highest version first. The version selected will be the first version found that all component selection rules accept.

A version is considered accepted if no rule explicitly rejects it.

Similarly, rules can be targeted at specific modules. Modules must be specified in the form of group:module:

build.gradle.kts
configurations {
    create("targetConfig") {
        resolutionStrategy {
            componentSelection {
                withModule("org.sample:api") {
                    if (candidate.version == "1.5") {
                        reject("version 1.5 is broken for 'org.sample:api'")
                    }
                }
            }
        }
    }
}
build.gradle
configurations {
    targetConfig {
        resolutionStrategy {
            componentSelection {
                withModule("org.sample:api") { ComponentSelection selection ->
                    if (selection.candidate.version == "1.5") {
                        selection.reject("version 1.5 is broken for 'org.sample:api'")
                    }
                }
            }
        }
    }
}

Component selection rules can also consider component metadata when selecting a version. Possible additional metadata that can be considered are ComponentMetadata and IvyModuleDescriptor.

Note that this extra information may not always be available and thus should be checked for null values:

build.gradle.kts
configurations {
    create("metadataRulesConfig") {
        resolutionStrategy {
            componentSelection {
                // Reject any versions with a status of 'experimental'
                all {
                    if (candidate.group == "org.sample" && metadata?.status == "experimental") {
                        reject("don't use experimental candidates from 'org.sample'")
                    }
                }
                // Accept the highest version with either a "release" branch or a status of 'milestone'
                withModule("org.sample:api") {
                    if (getDescriptor(IvyModuleDescriptor::class)?.branch != "release" && metadata?.status != "milestone") {
                        reject("'org.sample:api' must have testing branch or milestone status")
                    }
                }
            }
        }
    }
}
build.gradle
configurations {
    metadataRulesConfig {
        resolutionStrategy {
            componentSelection {
                // Reject any versions with a status of 'experimental'
                all { ComponentSelection selection ->
                    if (selection.candidate.group == 'org.sample' && selection.metadata?.status == 'experimental') {
                        selection.reject("don't use experimental candidates from 'org.sample'")
                    }
                }
                // Accept the highest version with either a "release" branch or a status of 'milestone'
                withModule('org.sample:api') { ComponentSelection selection ->
                    if (selection.getDescriptor(IvyModuleDescriptor)?.branch != "release" && selection.metadata?.status != 'milestone') {
                        selection.reject("'org.sample:api' must be a release branch or have milestone status")
                    }
                }
            }
        }
    }
}

A ComponentSelection argument is always required as a parameter when declaring a component selection rule.

5. Default Dependencies

You can set default dependencies for a configuration to ensure that a default version is used when no explicit dependencies are specified.

This is useful for plugins that rely on versioned tools and want to provide a default if the user doesn’t specify a version:

build.gradle.kts
configurations {
    create("pluginTool") {
        defaultDependencies {
            add(project.dependencies.create("org.gradle:my-util:1.0"))
        }
    }
}
build.gradle
configurations {
    pluginTool {
        defaultDependencies { dependencies ->
            dependencies.add(project.dependencies.create("org.gradle:my-util:1.0"))
        }
    }
}

In this example, the pluginTool configuration will use org.gradle:my-util:1.0 as a default dependency unless another version is specified.

6. Excluding Transitive Dependencies

To completely exclude a transitive dependency for a particular configuration, use the Configuration.exclude(Map) method.

This approach will automatically exclude the specified transitive dependency from all dependencies declared within the configuration:

build.gradle.kts
configurations {
    "implementation" {
        exclude(group = "commons-collections", module = "commons-collections")
    }
}

dependencies {
    implementation("commons-beanutils:commons-beanutils:1.9.4")
    implementation("com.opencsv:opencsv:4.6")
}
build.gradle
configurations {
    implementation {
        exclude group: 'commons-collections', module: 'commons-collections'
    }
}

dependencies {
    implementation 'commons-beanutils:commons-beanutils:1.9.4'
    implementation 'com.opencsv:opencsv:4.6'
}

In this example, the commons-collections dependency will be excluded from the implementation configuration, regardless of whether it is a direct or transitive dependency.

7. Force Failed Resolution Strategies

Version conflicts can be forced to fail using:

  • failOnNonReproducibleResolution()

  • failOnDynamicVersions()

  • failOnChangingVersions()

  • failOnVersionConflict()

This will fail the build when conflicting versions of the same dependency are found:

build.gradle.kts
configurations.all {
    resolutionStrategy {
        failOnVersionConflict()
    }
}
build.gradle
configurations.all {
    resolutionStrategy {
        failOnVersionConflict()
    }
}

8. Disabling Transitive Dependencies

By default, Gradle resolves all transitive dependencies for a given module.

However, there are situations where you may want to disable this behavior, such as when you need more control over dependencies or when the dependency metadata is incorrect.

You can tell Gradle to disable transitive dependency management for a dependency by setting ModuleDependency.setTransitive(boolean) to false.

In the following example, transitive dependency resolution is disabled for the guava dependency:

build.gradle.kts
dependencies {
    implementation("com.google.guava:guava:23.0") {
        isTransitive = false
    }
}
build.gradle
dependencies {
    implementation('com.google.guava:guava:23.0') {
        transitive = false
    }
}

This ensures only the main artifact for guava is resolved, and none of its transitive dependencies will be included.

Disabling transitive dependency resolution will likely require you to declare the necessary runtime dependencies in your build script which otherwise would have been resolved automatically. Not doing so might lead to runtime classpath issues.

If you want to disable transitive resolution globally across all dependencies, you can set this behavior at the configuration level:

build.gradle.kts
configurations.all {
    isTransitive = false
}

dependencies {
    implementation("com.google.guava:guava:23.0")
}
build.gradle
configurations.all {
    transitive = false
}

dependencies {
    implementation 'com.google.guava:guava:23.0'
}

This disables transitive resolution for all dependencies in the project. Be aware that this may require you to manually declare any transitive dependencies that are required at runtime.

For more information, see Configuration.setTransitive(boolean).

9. Dependency Resolve Rules and Other Conditionals

Dependency resolve rules are executed for each dependency as it’s being resolved, providing a powerful API to modify a dependency’s attributes—such as group, name, or version—before the resolution is finalized.

This allows for advanced control over dependency resolution, enabling you to substitute one module for another during the resolution process.

This feature is particularly useful for implementing advanced dependency management patterns. With dependency resolve rules, you can redirect dependencies to specific versions or even different modules entirely, allowing you to enforce consistent versions across a project or override problematic dependencies:

build.gradle.kts
configurations.all {
    resolutionStrategy {
        eachDependency {
            if (requested.group == "com.example" && requested.name == "old-library") {
                useTarget("com.example:new-library:1.0.0")
                because("Our license only allows use of version 1")
            }
        }
    }
}
build.gradle
configurations.all {
    resolutionStrategy {
        eachDependency {
            if (requested.group == "com.example" && requested.name == "old-library") {
                useTarget("com.example:new-library:1.0.0")
                because("Our license only allows use of version 1")
            }
        }
    }
}

In this example, if a dependency on com.example:old-library is requested, it will be substituted with com.example:new-library:1.0.0 during resolution.

For more advanced usage and additional examples, refer to the ResolutionStrategy class in the API documentation.

Implementing a custom versioning scheme

In some corporate environments, module versions in Gradle builds are maintained and audited externally. Dependency resolve rules offer an effective way to implement this:

  • Developers declare dependencies in the build script using the module’s group and name, but specify a placeholder version like default.

  • A dependency resolve rule then resolves the default version to an approved version, which is retrieved from a corporate catalog of sanctioned modules.

This approach ensures that only approved versions are used, while allowing developers to work with a simplified and consistent versioning scheme.

The rule implementation can be encapsulated in a corporate plugin, making it easy to apply across all projects within the organization:

build.gradle.kts
configurations.all {
    resolutionStrategy.eachDependency {
        if (requested.version == "default") {
            val version = findDefaultVersionInCatalog(requested.group, requested.name)
            useVersion(version.version)
            because(version.because)
        }
    }
}

data class DefaultVersion(val version: String, val because: String)

fun findDefaultVersionInCatalog(group: String, name: String): DefaultVersion {
    //some custom logic that resolves the default version into a specific version
    return DefaultVersion(version = "1.0", because = "tested by QA")
}
build.gradle
configurations.all {
    resolutionStrategy.eachDependency { DependencyResolveDetails details ->
        if (details.requested.version == 'default') {
            def version = findDefaultVersionInCatalog(details.requested.group, details.requested.name)
            details.useVersion version.version
            details.because version.because
        }
    }
}

def findDefaultVersionInCatalog(String group, String name) {
    //some custom logic that resolves the default version into a specific version
    [version: "1.0", because: 'tested by QA']
}

In this setup, whenever a developer specifies default as the version, the resolve rule replaces it with the approved version from the corporate catalog.

This strategy ensures compliance with corporate policies while providing flexibility and ease of use for developers. Encapsulating this logic in a plugin also ensures consistency across multiple projects.

Replacing unwanted dependency versions

Dependency resolve rules offer a powerful mechanism for blocking specific versions of a dependency and substituting them with an alternative.

This is particularly useful when a specific version is known to be problematic—such as a version that introduces bugs or relies on a library that isn’t available in public repositories. By defining a resolve rule, you can automatically replace a problematic version with a stable one.

Consider a scenario where version 1.2 of a library is broken, but version 1.2.1 contains important fixes and should always be used instead. With a resolve rule, you can enforce this substitution: "anytime version 1.2 is requested, it will be replaced with 1.2.1. Unlike forcing a version, this rule only affects the specific version 1.2, leaving other versions unaffected:

build.gradle.kts
configurations.all {
    resolutionStrategy.eachDependency {
        if (requested.group == "org.software" && requested.name == "some-library" && requested.version == "1.2") {
            useVersion("1.2.1")
            because("fixes critical bug in 1.2")
        }
    }
}
build.gradle
configurations.all {
    resolutionStrategy.eachDependency { DependencyResolveDetails details ->
        if (details.requested.group == 'org.software' && details.requested.name == 'some-library' && details.requested.version == '1.2') {
            details.useVersion '1.2.1'
            details.because 'fixes critical bug in 1.2'
        }
    }
}

If version 1.3 is also present in the dependency graph, then even with this rule, Gradle’s default conflict resolution strategy would select 1.3 as the latest version.

Difference from Rich Version Constraints: Using rich version constraints, you can reject certain versions outright, causing the build to fail or select a non-rejected version if a dynamic dependency is used. In contrast, a dependency resolve rule like the one shown here manipulates the version being requested, replacing it with a known good version when a rejected one is found. This approach is a solution for handling rejected versions, while rich version constraints are about expressing the intent to avoid certain versions.

Lazily influencing resolved dependencies

Plugins can lazily influence dependencies by adding them conditionally or setting preferred versions when no version is specified by the user.

Below are two examples illustrating these use cases.

This example demonstrates how to add a dependency to a configuration based on some condition, evaluated lazily:

build.gradle.kts
configurations {
    implementation {
        dependencies.addLater(project.provider {
            val dependencyNotation = conditionalLogic()
            if (dependencyNotation != null) {
                project.dependencies.create(dependencyNotation)
            } else {
                null
            }
        })
    }
}
build.gradle
configurations {
    implementation {
        dependencies.addLater(project.provider {
            def dependencyNotation = conditionalLogic()
            if (dependencyNotation != null) {
                return project.dependencies.create(dependencyNotation)
            } else {
                return null
            }
        })
    }
}

In this case, addLater is used to defer the evaluation of the dependency, allowing it to be added only when certain conditions are met.

In this example, the build script sets a preferred version of a dependency, which will be used if no version is explicitly specified:

Example 2: Preferring a Default Version of a Dependency

build.gradle.kts
dependencies {
    implementation("org:foo")

    // Can indiscriminately be added by build logic
    constraints {
        implementation("org:foo:1.0") {
            version {
                // Applied to org:foo if no other version is specified
                prefer("1.0")
            }
        }
    }
}
build.gradle
dependencies {
    implementation("org:foo")

    // Can indiscriminately be added by build logic
    constraints {
        implementation("org:foo:1.0") {
            version {
                // Applied to org:foo if no other version is specified
                prefer("1.0")
            }
        }
    }
}

This ensures that org:foo uses version 1.0 unless the user specifies another version.