Gradle Platforms, Constraints & Rich Versions

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 ran on JDK 26; nothing is compiled, so no toolchain was involved). Every example was run through the wrapper in scratch copies of a small project, once with Kotlin DSL build files and once with Groovy DSL build files, and the output is the same in both except where the text says otherwise. The scratch copies pointed their wrapper at a local copy of the 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 -q. The legend lines that Gradle prints under a dependency report ((c) and (*)) and the Try: hints at the end of a failure are left out, and so is the table of variant attributes in a dependencyInsight report, which is shown as .... The reports (dependencies, dependencyInsight), the probeVersions helper and publishing were checked with --configuration-cache (stored, then reused) and are compatible with it; the examples that fail on purpose were run without it. Gradle 8.14.x was not installed here, so statements about it come from the official documentation and are labelled "not run here".

A version in a dependency declaration is a small program: it says which versions are acceptable, which one is preferred, and how strongly the declaring build cares. Gradle collects those statements from the whole dependency graph and picks one version per module. This page is about the vocabulary for those statements and the objects that carry them:

  • rich versions (require, strictly, prefer, reject) say how much a single declaration cares;

  • dependency constraints say something about a module without adding it to the graph;

  • platforms, either imported from a Maven BOM or built in Gradle, are sets of constraints that travel as one dependency;

  • alignment makes modules that belong together use one version.

How the chosen version is then computed (graph resolution, capability conflicts, the "highest wins" rule itself) is on Dependency Resolution, Conflicts & Capabilities; declaring dependencies and the configurations they live in on Declaring Dependencies & Configurations; catalogs that centralise the coordinates on Version Catalogs; forcing and substituting on Resolution Rules & Substitution; and pinning the result on Dependency Locking, Verification & Caching. The tool-neutral comparison of coordinates, scopes, conflicts and BOMs is Dependency Management Fundamentals.

The scenario used on this page

To make every dependency graph exact, the examples resolve against two kinds of modules. The first is a small local file repository of made-up modules in the group io.tidepool.demo (a POM and an empty JAR for each version, generated by a script), so that the result does not change when someone publishes a new release to Maven Central. The second is real software from Maven Central where the real thing teaches more: the JUnit BOM 6.1.3 and Apache Commons CSV 1.14.1. Two projects named after the tidepool modules of the other pages (tidepool-model and tidepool-engine, here without any source code) are published to a local repository for the platform and alignment examples.

Module Versions What it depends on

harmonics

1.0.0, 1.1.0, 1.2.0, 1.3.0, 2.0.0

nothing

tides-api

1.0.0, 1.1.0

harmonics at the same version number as itself (1.0.0 needs 1.0.0, 1.1.0 needs 1.1.0)

tides-chart

1.0.0

harmonics:1.2.0

suite-core, suite-api, suite-ui

1.0.0, 1.1.0

suite-api and suite-ui need the suite-core of their own version

tidepool-bom

1.0.0

a Maven BOM that manages harmonics:1.2.0 and tides-api:1.1.0

suite-bom

1.1.0

a Maven BOM that manages suite-core, suite-api and suite-ui at 1.1.0

The settings file of the scratch build declares the repositories once (see Repositories & Content Filtering); the file repository is restricted by a content filter so that Gradle never asks Maven Central about the made-up group. Every example is a subproject that applies java-library, and the listings below show only its dependencies (and anything else that is not that plugin line).

Kotlin DSL
rootProject.name = "pcr"

dependencyResolutionManagement {
    repositoriesMode = RepositoriesMode.FAIL_ON_PROJECT_REPOS
    repositories {
        maven {
            url = uri(settingsDir.resolve("../repo"))
            content { includeGroup("io.tidepool.demo") }
        }
        maven {
            url = uri(settingsDir.resolve("../publisher/build/repo"))
            content { includeGroup("io.tidepool") }
        }
        mavenCentral()
    }
}

// one subproject per example, named as in the listings below

// included only on request: its configuration fails on purpose
if (providers.gradleProperty("badPlatform").isPresent) {
    include("platform-bad")
}
Groovy DSL
rootProject.name = 'pcr'

dependencyResolutionManagement {
    repositoriesMode = RepositoriesMode.FAIL_ON_PROJECT_REPOS
    repositories {
        maven {
            url = new File(settingsDir, '../repo')
            content { includeGroup 'io.tidepool.demo' }
        }
        maven {
            url = new File(settingsDir, '../publisher/build/repo')
            content { includeGroup 'io.tidepool' }
        }
        mavenCentral()
    }
}

// one subproject per example, named as in the listings below

// included only on request: its configuration fails on purpose
if (providers.gradleProperty('badPlatform').present) {
    include 'platform-bad'
}

A report is produced like this, for a project named rich-default, whose graph is the simplest one: harmonics declared at 1.0.0, and tides-api:1.1.0 which needs harmonics:1.1.0.

Kotlin DSL
dependencies {
    implementation("io.tidepool.demo:harmonics:1.0.0")
    implementation("io.tidepool.demo:tides-api:1.1.0")
}
Groovy DSL
dependencies {
    implementation 'io.tidepool.demo:harmonics:1.0.0'
    implementation 'io.tidepool.demo:tides-api:1.1.0'
}
./gradlew :rich-default:dependencies --configuration runtimeClasspath
runtimeClasspath - Runtime classpath of source set 'main'.
+--- io.tidepool.demo:harmonics:1.0.0 -> 1.1.0
\--- io.tidepool.demo:tides-api:1.1.0
     \--- io.tidepool.demo:harmonics:1.1.0

A plain version is a require: "at least this one". The conflict was settled by taking the higher of the two requirements, and -> 1.1.0 is how the report writes "this was asked for, that was selected".

Version declarations and ordering

A version string is one of five shapes. The shapes decide which versions are candidates; the rest of this page decides which candidate wins.

Shape Examples Meaning

Exact version

1.3, 1.3.0-beta3

One version. As a plain declaration it is a require (see below), not a pin.

Maven-style range

[1.0,), [1.1, 2.0[, (1.2, 1.5]

[ and ] include the bound, ( and ) exclude it, and ] or [ may be used in place of ( or ) for an exclusive bound. A missing bound is open. An exclusive upper bound acts as a prefix exclude.

Prefix range

1.+, 1.3.+

Versions that start with the part before the +. A bare + is any version.

Latest status

latest.release, latest.integration

The highest version with that status in the repository metadata.

Maven SNAPSHOT

1.0-SNAPSHOT

A changing version: the same coordinates may point to different content over time.

Whether a version lies in a range, and which of two versions is newer in a conflict, is decided by the version ordering. It is not semantic versioning, and it is not Maven’s comparator either (see Versions, SNAPSHOTs & Ranges):

  • A version is cut into parts at ., -, _ and +, and at every change between digits and letters, so 1a1 is 1.a.1. Only the parts are compared, never the separators: 1.a.1, 1-a+1, 1.a-1 and 1a1 are equal.

  • A number is lower than a bigger number, and any number is higher than a non-number: 1.a is older than 1.1.

  • Non-numbers compare alphabetically and case-sensitively: 1.A < 1.B < 1.a < 1.b.

  • A version with an extra numeric part is higher, even when the part is zero, so 1.1 is older than 1.1.0. A version with an extra non-numeric part is lower: 1.1.a is older than 1.1.

  • dev is lower than any other word, so 1.0-dev < 1.0-ALPHA < 1.0-alpha < 1.0-rc. And rc, snapshot, final, ga, release and sp are higher than any other word, in that order, regardless of case.

The "extra numeric part" rule bites in ranges: 1.3 is not the same version as 1.3.0, and 1.3.0 is higher. A range whose upper bound is written 1.3 therefore does not include 1.3.0, as the dynamic-version run in Dynamic versions and changing modules shows.

Rich versions

A dependency declaration that only carries a version string is a shorthand. The full form is a version constraint with up to four parts, reached through the version { } block of a dependency or a constraint. From the weakest to the strongest:

Term What it says Dynamic versions allowed

prefer

The softest statement: "use this if nobody has a stronger opinion". It applies only when no stronger, non-dynamic version is specified, and it complements require or strictly.

No

require

The selected version can not be lower than this, but conflict resolution may select a higher one, even above an exclusive upper bound. It is what a plain "g:a:1.2" means, and the default for a direct dependency.

Yes

strictly (or !!)

Only versions inside this range are acceptable, nothing outside it can be selected, and on a direct dependency it may downgrade. For a transitive dependency with no acceptable version, resolution fails. It overrides a require and clears earlier reject`s, and it is meant to replace `force.

Yes

reject

Outside the hierarchy: versions that must not be selected. If one would be selected, resolution fails or moves on. It does not constrain the rest of the range.

Yes

The manual’s guidance on which one to use is worth keeping next to this page:

Goal Declaration Why

Prevent a transitive upgrade past a known-good version

strictly("[1.9, 1.10[")

A strict range can not be overridden by a deeper participant in the graph.

Exclude one known-broken version

require("[1.0, 2.0[") and reject("1.4")

reject removes versions from the candidate set without constraining the range.

Bound the versions for the consumers of a library, but stay flexible

strictly("[1.0, 2.0[") and prefer("1.7.25")

The strict range caps consumers; prefer is the default inside it.

Enforce a minimum for a security fix

strictly("[2.17.1, 2.18[") inside constraints { }

A constraint only applies when the module is in the graph.

Make a constraint really cap a version

strictly inside the constraint

A bare version in constraints { } is a require, which still permits upgrades.

strictly: a range that can downgrade

rich-strictly-range declares harmonics with strictly("[1.0, 1.2[") and adds tides-api:1.1.0 (which wants harmonics:1.1.0) and tides-chart:1.0.0 (which wants harmonics:1.2.0, outside the range). Without the strict range the highest requirement, 1.2.0, would win. With it, 1.2.0 can not be selected, so tides-chart's requirement is overridden and the highest version inside the range, 1.1.0, is chosen:

Kotlin DSL
dependencies {
    implementation("io.tidepool.demo:harmonics") {
        version { strictly("[1.0, 1.2[") }
    }
    implementation("io.tidepool.demo:tides-api:1.1.0")
    implementation("io.tidepool.demo:tides-chart:1.0.0")
}
Groovy DSL
dependencies {
    implementation('io.tidepool.demo:harmonics') {
        version { strictly '[1.0, 1.2[' }
    }
    implementation 'io.tidepool.demo:tides-api:1.1.0'
    implementation 'io.tidepool.demo:tides-chart:1.0.0'
}
runtimeClasspath - Runtime classpath of source set 'main'.
+--- io.tidepool.demo:harmonics:{strictly [1.0, 1.2[} -> 1.1.0
+--- io.tidepool.demo:tides-api:1.1.0
|    \--- io.tidepool.demo:harmonics:1.1.0
\--- io.tidepool.demo:tides-chart:1.0.0
     \--- io.tidepool.demo:harmonics:1.2.0 -> 1.1.0

The Maven way to downgrade a transitive version is a dependencyManagement entry (see Dependency Management & BOMs); Gradle makes the statement on the dependency itself, and the report shows it as {strictly [1.0, 1.2[} followed by the selected version.

strictly with prefer: the preferred version wins inside the range

Adding prefer("1.0.0") to the same range changes the result. Nobody requires 1.0.0, and tides-api requires 1.1.0, which is inside the range, so one could expect 1.1.0. The result is 1.0.0:

Kotlin DSL
dependencies {
    implementation("io.tidepool.demo:harmonics") {
        version {
            strictly("[1.0, 1.2[")
            prefer("1.0.0")
        }
    }
    implementation("io.tidepool.demo:tides-api:1.1.0")
    implementation("io.tidepool.demo:tides-chart:1.0.0")
}
Groovy DSL
dependencies {
    implementation('io.tidepool.demo:harmonics') {
        version {
            strictly '[1.0, 1.2['
            prefer '1.0.0'
        }
    }
    implementation 'io.tidepool.demo:tides-api:1.1.0'
    implementation 'io.tidepool.demo:tides-chart:1.0.0'
}
runtimeClasspath - Runtime classpath of source set 'main'.
+--- io.tidepool.demo:harmonics:{strictly [1.0, 1.2[; prefer 1.0.0} -> 1.0.0
+--- io.tidepool.demo:tides-api:1.1.0
|    \--- io.tidepool.demo:harmonics:1.1.0 -> 1.0.0
\--- io.tidepool.demo:tides-chart:1.0.0
     \--- io.tidepool.demo:harmonics:1.2.0 -> 1.0.0

Requirements of transitive dependencies are overridden by a direct strictly, including the ones that are inside its range. Once they are out of the way, nothing but the prefer is left to choose between 1.0.0 and 1.1.0, and prefer picks 1.0.0. Pairing strictly with prefer therefore says "inside this range, use exactly this unless a direct requirement or a constraint asks for something else". It is the right shape for a library that wants to give its consumers room to move (strictly("[1.0, 2.0[") and prefer("1.1.0")), as the publishing example below shows; for an application that only wants a ceiling, a strictly range without prefer, as in the previous example, selects the highest version in the range.

The !! shorthand

Both strictly and prefer can be written in the string notation: 1.7.15!! is strictly("1.7.15"), and [1.7, 1.8[!!1.7.25 is strictly("[1.7, 1.8[") with prefer("1.7.25"). A project with the shorthand for the range above resolves exactly as the one with the version { } block:

Kotlin DSL
dependencies {
    implementation("io.tidepool.demo:harmonics:[1.0, 1.2[!!1.0.0")
    implementation("io.tidepool.demo:tides-api:1.1.0")
}
Groovy DSL
dependencies {
    implementation 'io.tidepool.demo:harmonics:[1.0, 1.2[!!1.0.0'
    implementation 'io.tidepool.demo:tides-api:1.1.0'
}
runtimeClasspath - Runtime classpath of source set 'main'.
+--- io.tidepool.demo:harmonics:{strictly [1.0, 1.2[; prefer 1.0.0} -> 1.0.0
\--- io.tidepool.demo:tides-api:1.1.0
     \--- io.tidepool.demo:harmonics:1.1.0 -> 1.0.0

reject: remove a known-broken version

reject takes versions out of the candidate set without capping the range. Here 1.2.0 is declared bad. The range [1.0, 1.3[ would select 1.2.0 (the upper bound is exclusive and acts as a prefix exclude, so 1.3.0 is out); with the rejection it falls back to 1.1.0:

Kotlin DSL
dependencies {
    implementation("io.tidepool.demo:harmonics") {
        version {
            require("[1.0, 1.3[")
            reject("1.2.0")
        }
    }
}
Groovy DSL
dependencies {
    implementation('io.tidepool.demo:harmonics') {
        version {
            require '[1.0, 1.3['
            reject '1.2.0'
        }
    }
}
runtimeClasspath - Runtime classpath of source set 'main'.
\--- io.tidepool.demo:harmonics:{require [1.0, 1.3[; reject 1.2.0} -> 1.1.0

dependencyInsight records why. The reason is rejected version 1.2.0:

./gradlew :rich-reject:dependencyInsight --dependency harmonics --configuration runtimeClasspath
io.tidepool.demo:harmonics:1.1.0
  Variant runtime:
    ...
   Selection reasons:
      - Was requested: rejected version 1.2.0
io.tidepool.demo:harmonics:{require [1.0, 1.3[; reject 1.2.0} -> 1.1.0
\--- runtimeClasspath

reject accepts several versions, and if nothing is left the build fails instead of silently choosing something. With require("[1.0, 1.3[") and all of 1.0.0, 1.1.0 and 1.2.0 rejected, the failure names the declaration and the versions it ruled out (the listing is from a small helper task, resolve, that only resolves the runtime classpath; the Groovy DSL output is identical except that the first line says build.gradle instead of build.gradle.kts):

Kotlin DSL
dependencies {
    implementation("io.tidepool.demo:harmonics") {
        version {
            require("[1.0, 1.3[")
            reject("1.0.0", "1.1.0", "1.2.0")
        }
    }
}

tasks.register("resolve") {
    inputs.files(configurations.runtimeClasspath)
    doLast { println("resolved") }
}
Groovy DSL
dependencies {
    implementation('io.tidepool.demo:harmonics') {
        version {
            require '[1.0, 1.3['
            reject '1.0.0', '1.1.0', '1.2.0'
        }
    }
}

tasks.register('resolve') {
    inputs.files(configurations.runtimeClasspath)
    doLast { println 'resolved' }
}
FAILURE: Build failed with an exception.
* What went wrong:
Execution failed for task ':reject-all:resolve' (registered in build file 'reject-all/build.gradle.kts').
> Could not resolve all files for configuration ':reject-all:runtimeClasspath'.
   > Could not resolve io.tidepool.demo:harmonics:{require [1.0, 1.3[; reject 1.0.0 & 1.1.0 & 1.2.0}.
     Required by:
         project ':reject-all'
      > Cannot find a version of 'io.tidepool.demo:harmonics' that satisfies the version constraints:
           Dependency path: project ':reject-all' (runtimeClasspath) --> 'io.tidepool.demo:harmonics:{require [1.0, 1.3[; reject 1.0.0 & 1.1.0 & 1.2.0}' because of the following reason: rejected versions 1.2.0, 1.1.0, 1.0.0

This one was run without the configuration cache, because with it the failure shows up when Gradle writes the cache entry.

Competing strictly declarations

Two strict statements about the same module are not combined arbitrarily. The manual distinguishes two cases, and both were run.

At the same level they intersect. A direct dependency and a constraint of the same project count as the same level. If the two ranges overlap, the selection is the highest version inside the overlap; if they do not, the build fails and names both:

Kotlin DSL
dependencies {
    implementation("io.tidepool.demo:harmonics") {
        version { strictly("[1.0, 1.2[") }
    }
    constraints {
        implementation("io.tidepool.demo:harmonics") {
            version { strictly("[1.2, 2.0[") }
        }
    }
}

tasks.register("resolve") {
    inputs.files(configurations.runtimeClasspath)
    doLast { println("resolved") }
}
Groovy DSL
dependencies {
    implementation('io.tidepool.demo:harmonics') {
        version { strictly '[1.0, 1.2[' }
    }
    constraints {
        implementation('io.tidepool.demo:harmonics') {
            version { strictly '[1.2, 2.0[' }
        }
    }
}

tasks.register('resolve') {
    inputs.files(configurations.runtimeClasspath)
    doLast { println 'resolved' }
}
FAILURE: Build failed with an exception.
* What went wrong:
Execution failed for task ':strict-conflict:resolve' (registered in build file 'strict-conflict/build.gradle.kts').
> Could not resolve all files for configuration ':strict-conflict:runtimeClasspath'.
   > Could not resolve io.tidepool.demo:harmonics:{strictly [1.0, 1.2[}.
     Required by:
         project ':strict-conflict'
      > Cannot find a version of 'io.tidepool.demo:harmonics' that satisfies the version constraints:
           Dependency path: project ':strict-conflict' (runtimeClasspath) --> 'io.tidepool.demo:harmonics:{strictly [1.0, 1.2[}' because of the following reason: rejected versions 1.1.0, 1.0.0
           Constraint path: project ':strict-conflict' (runtimeClasspath) --> 'io.tidepool.demo:harmonics:{strictly [1.2, 2.0[}' because of the following reason: rejected versions 1.3.0, 1.2.0
> There is 1 more failure with an identical cause.

At different depths the root-most one wins outright. A strict statement from a dependency’s own constraints is dropped, not intersected, when the root project says something strict about the same module. Here the root asks for [1.0, 2.0[ and the library project lvl-a constrains the module to [1.1, 1.3[. If the two were intersected the answer would be inside the narrower range, 1.2.0; the root’s range alone is used, and the highest version in it is 1.3.0:

Kotlin DSL
dependencies {
    constraints {
        api("io.tidepool.demo:harmonics") {
            version { strictly("[1.1, 1.3[") }
        }
    }
}
Groovy DSL
dependencies {
    constraints {
        api('io.tidepool.demo:harmonics') {
            version { strictly '[1.1, 1.3[' }
        }
    }
}
Kotlin DSL
dependencies {
    implementation(project(":lvl-a"))
    implementation("io.tidepool.demo:harmonics") {
        version { strictly("[1.0, 2.0[") }
    }
}
Groovy DSL
dependencies {
    implementation project(':lvl-a')
    implementation('io.tidepool.demo:harmonics') {
        version { strictly '[1.0, 2.0[' }
    }
}
runtimeClasspath - Runtime classpath of source set 'main'.
+--- project ':lvl-a'
|    \--- io.tidepool.demo:harmonics:{strictly [1.1, 1.3[} -> 1.3.0 (c)
\--- io.tidepool.demo:harmonics:{strictly [1.0, 2.0[} -> 1.3.0

This protects the right of a build to decide about its direct dependencies without negotiating with whatever it depends on. The one thing above it is the build’s own resolutionStrategy: force and eachDependency { useVersion(...) } replace the result even when it contradicts a strictly (see Resolution Rules & Substitution).

Dynamic versions and changing modules

A dynamic version is a range or a latest-status placeholder where an exact version would be: 1.+, [1.1,), latest.release. Gradle asks the repository for the list of versions and picks one at resolution time, so the same build file can resolve differently tomorrow. The helper below creates one resolvable configuration per notation and prints what each one selected, against the five versions of harmonics (1.0.0, 1.1.0, 1.2.0, 1.3.0, 2.0.0). It is compatible with the configuration cache (the second run reused the entry) because it only captures providers:

Kotlin DSL
plugins { `java-library` }

val notations = listOf("1.+", "1.1.+", "[1.0, 1.3[", "(1.0, 1.3]", "(1.0.0, 1.3.0]", "[1.1,)", "latest.release", "latest.integration", "+")

val probes = notations.mapIndexed { i, notation ->
    val bucket = configurations.dependencyScope("probe$i")
    dependencies.add(bucket.name, "io.tidepool.demo:harmonics:$notation")
    val classpath = configurations.resolvable("probe${i}Classpath") { extendsFrom(bucket.get()) }
    notation to classpath.flatMap { it.incoming.resolutionResult.rootComponent }.map { root ->
        root.dependencies.filterIsInstance<ResolvedDependencyResult>()
            .joinToString { it.selected.moduleVersion!!.version }
    }
}

tasks.register("probeVersions") {
    val rows = probes
    doLast {
        rows.forEach { (notation, version) -> println("%-20s -> %s".format(notation, version.get())) }
    }
}
Groovy DSL
plugins { id 'java-library' }

def notations = ['1.+', '1.1.+', '[1.0, 1.3[', '(1.0, 1.3]', '(1.0.0, 1.3.0]', '[1.1,)', 'latest.release', 'latest.integration', '+']

def probes = notations.indexed().collect { i, notation ->
    def bucket = configurations.dependencyScope("probe$i")
    dependencies.add(bucket.name, "io.tidepool.demo:harmonics:$notation")
    def classpath = configurations.resolvable("probe${i}Classpath") { extendsFrom(bucket.get()) }
    [notation, classpath.flatMap { it.incoming.resolutionResult.rootComponent }.map { root ->
        root.dependencies.findAll { it instanceof ResolvedDependencyResult }
            .collect { it.selected.moduleVersion.version }.join(', ')
    }]
}

tasks.register('probeVersions') {
    def rows = probes
    doLast {
        rows.each { notation, version -> println String.format('%-20s -> %s', notation, version.get()) }
    }
}
./gradlew :dynamic:probeVersions --configuration-cache
1.+                  -> 1.3.0
1.1.+                -> 1.1.0
[1.0, 1.3[           -> 1.2.0
(1.0, 1.3]           -> 1.2.0
(1.0.0, 1.3.0]       -> 1.3.0
[1.1,)               -> 2.0.0
latest.release       -> 2.0.0
latest.integration   -> 2.0.0
+                    -> 2.0.0

Two rows deserve a second look. (1.0, 1.3] includes its upper bound, but the bound is the version 1.3, which is older than 1.3.0; the same range written (1.0.0, 1.3.0] selects 1.3.0. And latest.release and latest.integration select 2.0.0 here because the repository metadata gives every version the status release; latest.integration would also select snapshots, if there were any. The Groovy DSL run printed the same table.

Dynamic versions are cached. A resolved version list is kept in the dependency cache for 24 hours by default, so a new release is not seen at once. For a file repository Gradle does not cache anything, so the demonstration needs a remote one: a throwaway HTTP server on localhost (Python’s http.server, started for the run and stopped after it) serving a module tides-live with versions 1.0.0 and 1.1.0, resolved with 1.+. After the first run a version 1.2.0 was added to the server. The second run still resolved 1.1.0, from the cached version list. The third run used cacheDynamicVersionsFor(0, "seconds") (switched on with -PnoCache), so Gradle asked again and found 1.2.0. The three runs shared one fresh Gradle user home, so the cache started empty:

Kotlin DSL
dependencies {
    implementation("io.tidepool.live:tides-live:1.+")
}

if (providers.gradleProperty("noCache").isPresent) {
    configurations.configureEach {
        resolutionStrategy.cacheDynamicVersionsFor(0, "seconds")
    }
}
Groovy DSL
dependencies {
    implementation 'io.tidepool.live:tides-live:1.+'
}

if (providers.gradleProperty('noCache').present) {
    configurations.configureEach {
        resolutionStrategy.cacheDynamicVersionsFor 0, 'seconds'
    }
}
$ ./gradlew dependencies --configuration runtimeClasspath
\--- io.tidepool.live:tides-live:1.+ -> 1.1.0
$ (1.2.0 is published) ./gradlew dependencies --configuration runtimeClasspath
\--- io.tidepool.live:tides-live:1.+ -> 1.1.0
$ ./gradlew dependencies --configuration runtimeClasspath -PnoCache
\--- io.tidepool.live:tides-live:1.+ -> 1.2.0

Both DSLs gave the same three results. The repository was declared with isAllowInsecureProtocol = true (allowInsecureProtocol in the Groovy DSL), which is acceptable for localhost and for nothing else. A changing module is the other half: a version that keeps its coordinates while its content changes, such as a Maven SNAPSHOT or any dependency declared with isChanging = true; its cache is controlled by cacheChangingModulesFor. isChanging and cacheChangingModulesFor were not run here. The way to get reproducible builds from dynamic versions is dependency locking, on Dependency Locking, Verification & Caching.

The same idea in the other tools: Maven’s ranges and LATEST/RELEASE (and the SNAPSHOT update policy) are on Versions, SNAPSHOTs & Ranges, and Ivy’s dynamic revisions (latest.integration, 1.+, [1.0,2.0]) on Ivy: Conflicts & Versions.

Dependency constraints

A dependency constraint sets a version requirement for a module without adding the module to the graph. If something else brings the module in, the constraint takes part in conflict resolution like a declared version; if nothing does, it is ignored. That is what makes it the right tool for transitive dependencies: a security floor, a "tested with" version, a way to keep the version declaration out of the places that use the module. Constraints are declared in a constraints { } block inside dependencies { }, are scoped to a configuration like any dependency, and can carry a because message.

Raising a transitive version

tides-api:1.0.0 pulls in harmonics:1.0.0. The project has no dependency on harmonics of its own and wants at least 1.2.0 whenever it appears. A second constraint is on a module that nothing in the graph uses:

Kotlin DSL
dependencies {
    implementation("io.tidepool.demo:tides-api:1.0.0")
    constraints {
        implementation("io.tidepool.demo:harmonics:1.2.0") {
            because("1.2.0 fixes the tidal-range rounding bug")
        }
        implementation("io.tidepool.demo:tides-chart:1.0.0")
    }
}
Groovy DSL
dependencies {
    implementation 'io.tidepool.demo:tides-api:1.0.0'
    constraints {
        implementation('io.tidepool.demo:harmonics:1.2.0') {
            because '1.2.0 fixes the tidal-range rounding bug'
        }
        implementation 'io.tidepool.demo:tides-chart:1.0.0'
    }
}
runtimeClasspath - Runtime classpath of source set 'main'.
+--- io.tidepool.demo:tides-api:1.0.0
|    \--- io.tidepool.demo:harmonics:1.0.0 -> 1.2.0
\--- io.tidepool.demo:harmonics:1.2.0 (c)

harmonics is in the graph because tides-api needs it, and the constraint raised it to 1.2.0 (the (c) marks the constraint). tides-chart does not appear at all: the constraint on it is a no-op because nothing depends on it. The reason Gradle selected 1.2.0 is recorded, with the text of because:

./gradlew :constraints:dependencyInsight --dependency harmonics --configuration runtimeClasspath
io.tidepool.demo:harmonics:1.2.0
  Variant runtime:
    ...
   Selection reasons:
      - By constraint: 1.2.0 fixes the tidal-range rounding bug
      - By conflict resolution: between versions 1.2.0 and 1.0.0
io.tidepool.demo:harmonics:1.2.0
\--- runtimeClasspath
io.tidepool.demo:harmonics:1.0.0 -> 1.2.0
\--- io.tidepool.demo:tides-api:1.0.0
     \--- runtimeClasspath

A bare version in a constraint does not cap anything

A constraint with a plain version is a require. It can raise a version, never lower one, and it does not stop a dependency from asking for more. tides-chart needs 1.2.0; the constraint says 1.1.0; the result is 1.2.0:

Kotlin DSL
dependencies {
    implementation("io.tidepool.demo:tides-chart:1.0.0")
    constraints {
        implementation("io.tidepool.demo:harmonics:1.1.0")
    }
}
Groovy DSL
dependencies {
    implementation 'io.tidepool.demo:tides-chart:1.0.0'
    constraints {
        implementation 'io.tidepool.demo:harmonics:1.1.0'
    }
}
runtimeClasspath - Runtime classpath of source set 'main'.
+--- io.tidepool.demo:tides-chart:1.0.0
|    \--- io.tidepool.demo:harmonics:1.2.0
\--- io.tidepool.demo:harmonics:1.1.0 -> 1.2.0 (c)

To cap a version, make the constraint strict. The range [1.0, 1.2[ with a preferred 1.1.0 cuts 1.2.0 out of the graph, and tides-chart is held back to 1.1.0 together with the declared reason:

Kotlin DSL
dependencies {
    implementation("io.tidepool.demo:tides-chart:1.0.0")
    constraints {
        implementation("io.tidepool.demo:harmonics") {
            version {
                strictly("[1.0, 1.2[")
                prefer("1.1.0")
            }
            because("1.2 changed the Constituent API")
        }
    }
}
Groovy DSL
dependencies {
    implementation 'io.tidepool.demo:tides-chart:1.0.0'
    constraints {
        implementation('io.tidepool.demo:harmonics') {
            version {
                strictly '[1.0, 1.2['
                prefer '1.1.0'
            }
            because '1.2 changed the Constituent API'
        }
    }
}
runtimeClasspath - Runtime classpath of source set 'main'.
+--- io.tidepool.demo:tides-chart:1.0.0
|    \--- io.tidepool.demo:harmonics:1.2.0 -> 1.1.0
\--- io.tidepool.demo:harmonics:{strictly [1.0, 1.2[; prefer 1.1.0} -> 1.1.0 (c)
./gradlew :strict-capped:dependencyInsight --dependency harmonics --configuration runtimeClasspath
io.tidepool.demo:harmonics:1.1.0
  Variant runtime:
    ...
   Selection reasons:
      - By ancestor
      - By constraint: 1.2 changed the Constituent API
io.tidepool.demo:harmonics:1.2.0 -> 1.1.0
\--- io.tidepool.demo:tides-chart:1.0.0
     \--- runtimeClasspath
io.tidepool.demo:harmonics:{strictly [1.0, 1.2[; prefer 1.1.0} -> 1.1.0
\--- runtimeClasspath

The Selection reasons show two things: the constraint with its because text, and By ancestor, which Gradle uses when a strict version declared elsewhere in the graph decided the selection. This is the pattern for pinning a transitive dependency without adding it as a direct one (a security fix, for instance): strictly("[2.17.1, 2.18[") inside constraints { }.

Constraints are transitive, and they are published

A constraint declared by a library is part of what it publishes, and it applies to everyone who uses the library. The tides-kit library built in Rich versions and constraints in published metadata declares a constraint tides-chart:1.0.0. A consumer that depends on tides-kit and on tides-chart without a version gets 1.0.0, from the library’s constraint:

Kotlin DSL
dependencies {
    implementation("io.tidepool:tides-kit:1.0.0")
    implementation("io.tidepool.demo:harmonics:1.3.0")
    implementation("io.tidepool.demo:tides-chart")
}
Groovy DSL
dependencies {
    implementation 'io.tidepool:tides-kit:1.0.0'
    implementation 'io.tidepool.demo:harmonics:1.3.0'
    implementation 'io.tidepool.demo:tides-chart'
}
runtimeClasspath - Runtime classpath of source set 'main'.
+--- io.tidepool:tides-kit:1.0.0
|    +--- io.tidepool.demo:tides-api:{require 1.0.0; reject 1.0.1} -> 1.0.0
|    |    \--- io.tidepool.demo:harmonics:1.0.0 -> 1.3.0
|    +--- io.tidepool.demo:harmonics:{strictly [1.0, 2.0[; prefer 1.1.0} -> 1.3.0
|    \--- io.tidepool.demo:tides-chart:1.0.0 (c)
+--- io.tidepool.demo:harmonics:1.3.0
\--- io.tidepool.demo:tides-chart -> 1.0.0
     \--- io.tidepool.demo:harmonics:1.2.0 -> 1.3.0

The library’s constraint is the tides-chart:1.0.0 (c) line under tides-kit. The manual says constraints are only guaranteed with Gradle Module Metadata and that Maven or Ivy consumers may not see them; in this run the constraint also survived into the POM, as a dependencyManagement entry (see the listings in Rich versions and constraints in published metadata), while the rich versions did not.

Importing a BOM with platform()

Libraries that ship as a family publish a bill of materials: a POM whose dependencyManagement lists the versions of all members (the tool-neutral picture is in Dependency Management Fundamentals, the Maven side in Dependency Management & BOMs). Gradle imports one with the platform(...) keyword. Gradle derives a platform variant from every Maven POM it consumes, so any POM with a dependencyManagement section can be imported this way, and every managed entry becomes a constraint, in exactly the sense of the previous section.

A real BOM: JUnit

The JUnit team publishes org.junit:junit-bom. With it, the test dependencies need no versions:

Kotlin DSL
dependencies {
    testImplementation(platform("org.junit:junit-bom:6.1.3"))
    testImplementation("org.junit.jupiter:junit-jupiter")
    testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}
Groovy DSL
dependencies {
    testImplementation platform('org.junit:junit-bom:6.1.3')
    testImplementation 'org.junit.jupiter:junit-jupiter'
    testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}
./gradlew :pub-junit:dependencies --configuration testRuntimeClasspath
testRuntimeClasspath - Runtime classpath of source set 'test'.
+--- org.junit:junit-bom:6.1.3
|    +--- org.junit.jupiter:junit-jupiter:6.1.3 (c)
|    +--- org.junit.platform:junit-platform-launcher:6.1.3 (c)
|    +--- org.junit.jupiter:junit-jupiter-engine:6.1.3 (c)
|    +--- org.junit.jupiter:junit-jupiter-api:6.1.3 (c)
|    +--- org.junit.jupiter:junit-jupiter-params:6.1.3 (c)
|    +--- org.junit.platform:junit-platform-engine:6.1.3 (c)
|    \--- org.junit.platform:junit-platform-commons:6.1.3 (c)
+--- org.junit.jupiter:junit-jupiter -> 6.1.3
|    +--- org.junit.jupiter:junit-jupiter-engine:6.1.3
|    |    +--- org.junit:junit-bom:6.1.3 (*)

(The listing is cut after its first lines; the rest of the tree continues in the same way.) The seven BOM entries are the constraints, marked (c); the two declared dependencies got their versions from them; and every JUnit module in the tree also points back at the BOM, because the JUnit modules themselves declare a dependency on it. The platform keyword also sets the org.gradle.category attribute to platform, so that Gradle selects the platform variant of the BOM and not a library, and it switches on endorseStrictVersions (see Strict versions in a platform are endorsed).

platform: a recommendation that can be outvoted

A platform only recommends. bom-platform imports tidepool-bom (which manages harmonics:1.2.0 and tides-api:1.1.0) and also declares harmonics:1.3.0 itself. tides-api is declared without a version and takes the BOM’s:

Kotlin DSL
dependencies {
    implementation(platform("io.tidepool.demo:tidepool-bom:1.0.0"))
    implementation("io.tidepool.demo:harmonics:1.3.0")
    implementation("io.tidepool.demo:tides-api")
}
Groovy DSL
dependencies {
    implementation platform('io.tidepool.demo:tidepool-bom:1.0.0')
    implementation 'io.tidepool.demo:harmonics:1.3.0'
    implementation 'io.tidepool.demo:tides-api'
}
runtimeClasspath - Runtime classpath of source set 'main'.
+--- io.tidepool.demo:tidepool-bom:1.0.0
|    +--- io.tidepool.demo:harmonics:1.2.0 -> 1.3.0 (c)
|    \--- io.tidepool.demo:tides-api:1.1.0 (c)
+--- io.tidepool.demo:harmonics:1.3.0
\--- io.tidepool.demo:tides-api -> 1.1.0
     \--- io.tidepool.demo:harmonics:1.1.0 -> 1.3.0

The direct 1.3.0 is higher than the BOM’s 1.2.0, so it wins: the report shows the BOM’s own entry as 1.2.0 -> 1.3.0, and the harmonics:1.1.0 that tides-api needs is raised to 1.3.0 as well.

enforcedPlatform: the BOM wins, even by downgrading

enforcedPlatform converts every BOM entry into a strictly. Nobody can raise a version above the BOM’s, and a direct declaration is overridden. The same project with enforcedPlatform:

Kotlin DSL
dependencies {
    implementation(enforcedPlatform("io.tidepool.demo:tidepool-bom:1.0.0"))
    implementation("io.tidepool.demo:harmonics:1.3.0")
    implementation("io.tidepool.demo:tides-api")
}
Groovy DSL
dependencies {
    implementation enforcedPlatform('io.tidepool.demo:tidepool-bom:1.0.0')
    implementation 'io.tidepool.demo:harmonics:1.3.0'
    implementation 'io.tidepool.demo:tides-api'
}
runtimeClasspath - Runtime classpath of source set 'main'.
+--- io.tidepool.demo:tidepool-bom:{strictly 1.0.0} -> 1.0.0
|    +--- io.tidepool.demo:harmonics:1.2.0 (c)
|    \--- io.tidepool.demo:tides-api:1.1.0 (c)
+--- io.tidepool.demo:harmonics:1.3.0 -> 1.2.0
\--- io.tidepool.demo:tides-api -> 1.1.0
     \--- io.tidepool.demo:harmonics:1.1.0 -> 1.2.0

The direct 1.3.0 is gone, 1.2.0 is what the BOM says, and the BOM itself is pinned: it appears as tidepool-bom:{strictly 1.0.0}. In a build’s own dependencies report, that annotation is how to recognise an enforced platform.

Why enforcedPlatform is discouraged for libraries. The strictness is part of what the build declares, and a library publishes it. A consumer of such a library can not simply ask for a different version of one of the pinned modules: it has to beat the strict statement from higher in the graph with its own strictly, or add a component metadata rule that calls doNotEndorseStrictVersions() on the transitive platform. Use enforcedPlatform in applications that own their whole graph; in a library, use a plain platform, or put strictly ranges (with a prefer) on the few modules that need one. The manual adds a rule of thumb: enforcedPlatform to pin all the versions of a BOM as a group, strictly on individual dependencies when one or two modules need control, and it warns that mixing a platform with a strictly on one of its modules lets the strictly drive the result for that module and its own transitive dependencies, leaving the BOM’s other members where the BOM put them.

A BOM and a version catalog together

A version catalog names the coordinates; a platform decides the versions of what they refer to. Both are done in one place by declaring the BOM in libs.versions.toml and wrapping its accessor in platform(...); a catalog entry can also carry a rich version. The catalog of the scratch build (shown as text, because the code highlighter has no TOML grammar):

[versions]
tidepool-bom = "1.0.0"

[libraries]
tidepool-bom = { module = "io.tidepool.demo:tidepool-bom", version.ref = "tidepool-bom" }
harmonics = { module = "io.tidepool.demo:harmonics", version = { strictly = "[1.0, 1.2[", prefer = "1.1.0" } }
tides-api = { module = "io.tidepool.demo:tides-api" }
Kotlin DSL
dependencies {
    implementation(platform(libs.tidepool.bom))
    implementation(libs.harmonics)
    implementation(libs.tides.api)
}
Groovy DSL
dependencies {
    implementation platform(libs.tidepool.bom)
    implementation libs.harmonics
    implementation libs.tides.api
}
runtimeClasspath - Runtime classpath of source set 'main'.
+--- io.tidepool.demo:tidepool-bom:1.0.0
|    +--- io.tidepool.demo:harmonics:1.2.0 -> 1.1.0 (c)
|    \--- io.tidepool.demo:tides-api:1.1.0 (c)
+--- io.tidepool.demo:harmonics:{strictly [1.0, 1.2[; prefer 1.1.0} -> 1.1.0
\--- io.tidepool.demo:tides-api -> 1.1.0
     \--- io.tidepool.demo:harmonics:1.1.0

The strict range on harmonics beat the BOM’s 1.2.0 (the BOM’s entry shows as -> 1.1.0), while tides-api got the BOM’s 1.1.0. See Version Catalogs for the catalog syntax, and the manual’s "Using Catalogs with Platforms" for when to prefer each.

Building a platform with the java-platform plugin

A Gradle platform is a component with no sources, made only of constraints (and, if you ask for them, dependencies), that other builds consume with platform(...). The java-platform plugin builds one. It describes modules published together (the same version), a set of recommended versions for heterogeneous libraries (the Spring Boot BOM is the usual example) or the versions shared by the subprojects of one build. The plugin can not be combined with java or java-library in the same project: a project is a platform, with no binaries, or it produces binaries.

The configurations it adds follow the shape of the Java ones.

Configuration Role Purpose

api

Declarable

Constraints (and dependencies) that apply when compiling against the platform. They are exported to consumers.

runtime

Declarable

Constraints that are visible only at runtime.

apiElements, runtimeElements

Consumable

What a consumer gets with platform(...), for compilation and for runtime.

enforcedApiElements, enforcedRuntimeElements

Consumable

The same constraints exposed as forced, for consumers that use enforcedPlatform(...).

A platform holds constraints, not dependencies, on purpose: a constraint only applies when something else brings the module in, so the platform never adds a library to a build that does not use it. To stop the common mistake of writing a dependency where a constraint was meant, the plugin refuses dependencies unless the build opts in. This project declares one (api("...") instead of constraints { api("...") }) and was included in the run only on request, because the failure happens while it is configured:

Kotlin DSL
dependencies {
    api("io.tidepool.demo:harmonics:1.0.0")
}
Groovy DSL
dependencies {
    api 'io.tidepool.demo:harmonics:1.0.0'
}
./gradlew -PbadPlatform :platform-bad:dependencies
FAILURE: Build failed with an exception.
* What went wrong:
A problem occurred configuring project ':platform-bad'.
> Adding dependencies to platforms is not allowed by default.
  Most likely you want to add constraints instead.
  If you did this intentionally, you need to configure the platform extension to allow dependencies:
      javaPlatform.allowDependencies()
  Found dependencies in the 'api' configuration.

The opt-in is javaPlatform { allowDependencies() }. It is also what makes it possible to build a platform from another one, by importing it with api(platform("g:a:v")), which is how a company BOM extends a third-party one. This variant was not run here.

The tidepool platform

The scenario of the other pages already has two libraries, tidepool-model and tidepool-engine. A platform project tidepool-platform lists the versions of both and of commons-csv, which the engine uses to read the stations file. The libraries depend on the platform, so that every published library points at the platform of its own version. The root build sets the group and the version (from -PpubVersion) and gives every subproject the same file repository, declared once; the libraries and the platform differ only in the plugin and the component published.

Kotlin DSL
allprojects {
    group = "io.tidepool"
    version = providers.gradleProperty("pubVersion").getOrElse("1.0.0")
}

subprojects {
    apply(plugin = "maven-publish")
    configure<PublishingExtension> {
        repositories {
            maven {
                name = "local"
                url = uri(rootProject.layout.buildDirectory.dir("repo"))
            }
        }
    }
}
Groovy DSL
allprojects {
    group = 'io.tidepool'
    version = providers.gradleProperty('pubVersion').getOrElse('1.0.0')
}

subprojects {
    apply plugin: 'maven-publish'
    publishing {
        repositories {
            maven {
                name = 'local'
                url = rootProject.layout.buildDirectory.dir('repo')
            }
        }
    }
}
Kotlin DSL
plugins { `java-platform` }

dependencies {
    constraints {
        api(project(":tidepool-model"))
        api(project(":tidepool-engine"))
        api("org.apache.commons:commons-csv:1.14.1")
    }
}

publishing {
    publications {
        create<MavenPublication>("platform") {
            from(components["javaPlatform"])
        }
    }
}
Groovy DSL
plugins { id 'java-platform' }

dependencies {
    constraints {
        api project(':tidepool-model')
        api project(':tidepool-engine')
        api 'org.apache.commons:commons-csv:1.14.1'
    }
}

publishing {
    publications {
        platform(MavenPublication) {
            from components.javaPlatform
        }
    }
}
Kotlin DSL
plugins { `java-library` }

dependencies {
    api(platform(project(":tidepool-platform")))
}

publishing {
    publications {
        create<MavenPublication>("lib") {
            from(components["java"])
        }
    }
}
Groovy DSL
plugins { id 'java-library' }

dependencies {
    api platform(project(':tidepool-platform'))
}

publishing {
    publications {
        lib(MavenPublication) {
            from components.java
        }
    }
}
Kotlin DSL
plugins { `java-library` }

dependencies {
    api(platform(project(":tidepool-platform")))
    api(project(":tidepool-model"))
    implementation("org.apache.commons:commons-csv")
}

publishing {
    publications {
        create<MavenPublication>("lib") {
            from(components["java"])
        }
    }
}
Groovy DSL
plugins { id 'java-library' }

dependencies {
    api platform(project(':tidepool-platform'))
    api project(':tidepool-model')
    implementation 'org.apache.commons:commons-csv'
}

publishing {
    publications {
        lib(MavenPublication) {
            from components.java
        }
    }
}

The constraints name the two projects, and Gradle turns the project notation into plain group:name:version coordinates in the published metadata. The platform is published like any component, from the component named javaPlatform instead of java. Publishing needs only maven-publish; the repository here is a directory under the build, as everywhere in this section (publishing to a real repository is on Publishing and is not run here). The commands, run once for each version:

./gradlew publishAllPublicationsToLocalRepository --configuration-cache
./gradlew publishAllPublicationsToLocalRepository -PpubVersion=1.1.0

The first run stored a configuration cache entry and a second identical run reused it, so the build is compatible. The platform’s POM is a BOM with a dependencyManagement block whose entries are the constraints:

<?xml version="1.0" encoding="UTF-8"?>
<project xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd" xmlns="http://maven.apache.org/POM/4.0.0"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
  <!-- This module was also published with a richer model, Gradle metadata,  -->
  <!-- which should be used instead. Do not delete the following line which  -->
  <!-- is to indicate to Gradle or any Gradle module metadata file consumer  -->
  <!-- that they should prefer consuming it instead. -->
  <!-- do_not_remove: published-with-gradle-metadata -->
  <modelVersion>4.0.0</modelVersion>
  <groupId>io.tidepool</groupId>
  <artifactId>tidepool-platform</artifactId>
  <version>1.0.0</version>
  <packaging>pom</packaging>
  <dependencyManagement>
    <dependencies>
      <dependency>
        <groupId>io.tidepool</groupId>
        <artifactId>tidepool-model</artifactId>
        <version>1.0.0</version>
      </dependency>
      <dependency>
        <groupId>io.tidepool</groupId>
        <artifactId>tidepool-engine</artifactId>
        <version>1.0.0</version>
      </dependency>
      <dependency>
        <groupId>org.apache.commons</groupId>
        <artifactId>commons-csv</artifactId>
        <version>1.14.1</version>
      </dependency>
    </dependencies>
  </dependencyManagement>
</project>

Its .module file (not shown) has an apiElements and a runtimeElements variant with the attribute org.gradle.category: platform and the same three constraints in dependencyConstraints. The engine’s POM shows the other end of the arrangement: the platform that the engine declared with api(platform(...)) is imported as a BOM, and commons-csv has no version because the platform supplies it:

<?xml version="1.0" encoding="UTF-8"?>
<project xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd" xmlns="http://maven.apache.org/POM/4.0.0"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
  <!-- This module was also published with a richer model, Gradle metadata,  -->
  <!-- which should be used instead. Do not delete the following line which  -->
  <!-- is to indicate to Gradle or any Gradle module metadata file consumer  -->
  <!-- that they should prefer consuming it instead. -->
  <!-- do_not_remove: published-with-gradle-metadata -->
  <modelVersion>4.0.0</modelVersion>
  <groupId>io.tidepool</groupId>
  <artifactId>tidepool-engine</artifactId>
  <version>1.0.0</version>
  <dependencyManagement>
    <dependencies>
      <dependency>
        <groupId>io.tidepool</groupId>
        <artifactId>tidepool-platform</artifactId>
        <version>1.0.0</version>
        <type>pom</type>
        <scope>import</scope>
      </dependency>
    </dependencies>
  </dependencyManagement>
  <dependencies>
    <dependency>
      <groupId>io.tidepool</groupId>
      <artifactId>tidepool-model</artifactId>
      <version>1.0.0</version>
      <scope>compile</scope>
    </dependency>
    <dependency>
      <groupId>org.apache.commons</groupId>
      <artifactId>commons-csv</artifactId>
      <scope>runtime</scope>
    </dependency>
  </dependencies>
</project>

A consumer imports the published platform and declares the two libraries and commons-csv without versions:

Kotlin DSL
dependencies {
    implementation(platform("io.tidepool:tidepool-platform:1.1.0"))
    implementation("io.tidepool:tidepool-engine")
    implementation("org.apache.commons:commons-csv")
}
Groovy DSL
dependencies {
    implementation platform('io.tidepool:tidepool-platform:1.1.0')
    implementation 'io.tidepool:tidepool-engine'
    implementation 'org.apache.commons:commons-csv'
}
runtimeClasspath - Runtime classpath of source set 'main'.
+--- io.tidepool:tidepool-platform:1.1.0
|    +--- io.tidepool:tidepool-engine:1.1.0 (c)
|    +--- org.apache.commons:commons-csv:1.14.1 (c)
|    \--- io.tidepool:tidepool-model:1.1.0 (c)
+--- io.tidepool:tidepool-engine -> 1.1.0
|    +--- org.apache.commons:commons-csv -> 1.14.1
|    |    +--- commons-io:commons-io:2.20.0
|    |    \--- commons-codec:commons-codec:1.19.0
|    +--- io.tidepool:tidepool-platform:1.1.0 (*)
|    \--- io.tidepool:tidepool-model:1.1.0
|         \--- io.tidepool:tidepool-platform:1.1.0 (*)
\--- org.apache.commons:commons-csv -> 1.14.1 (*)

The platform is the first line, with its three constraints ((c)); the engine and commons-csv took their versions from it; and tidepool-model, which the engine needs, is at the platform’s version too. The Groovy DSL output of this run, and of the other reports on this page, was identical.

The same structure inside one build, with the platform as a subproject and no publishing, is how a multi-project build shares its dependency versions without a version catalog or without allprojects configuration (see Multi Project Builds). The manual’s guidance on choosing between them is in "Using Catalogs with Platforms": a catalog gives type-safe accessors and consistent coordinates but does not affect resolution; a platform does.

Strict versions in a platform are endorsed

A platform can contain strictly constraints. When a consumer imports it with platform(...), those strict versions are treated as if the consumer had declared them: platform() switches on endorseStrictVersions by default. strict-platform pins harmonics to [1.0, 1.2[ with a preferred 1.1.0, and the consumer also depends on tides-chart (which wants 1.2.0):

Kotlin DSL
plugins { `java-platform` }

dependencies {
    constraints {
        api("io.tidepool.demo:harmonics") {
            version {
                strictly("[1.0, 1.2[")
                prefer("1.1.0")
            }
            because("the platform pins the tested harmonics line")
        }
    }
}
Groovy DSL
plugins { id 'java-platform' }

dependencies {
    constraints {
        api('io.tidepool.demo:harmonics') {
            version {
                strictly '[1.0, 1.2['
                prefer '1.1.0'
            }
            because 'the platform pins the tested harmonics line'
        }
    }
}
Kotlin DSL
dependencies {
    implementation(platform(project(":strict-platform")))
    implementation("io.tidepool.demo:tides-chart:1.0.0")
}
Groovy DSL
dependencies {
    implementation platform(project(':strict-platform'))
    implementation 'io.tidepool.demo:tides-chart:1.0.0'
}
runtimeClasspath - Runtime classpath of source set 'main'.
+--- project ':strict-platform'
|    \--- io.tidepool.demo:harmonics:{strictly [1.0, 1.2[; prefer 1.1.0} -> 1.1.0 (c)
\--- io.tidepool.demo:tides-chart:1.0.0
     \--- io.tidepool.demo:harmonics:1.2.0 -> 1.1.0

The strict range held tides-chart back to 1.1.0, as it would have if the consumer had written the range itself. A consumer that does not want the platform’s strict statements applied to the rest of its graph calls doNotEndorseStrictVersions() on the platform dependency. In the Kotlin DSL the function is not available on the lambda’s receiver of platform(...), which is a plain Dependency: the manual’s snippet fails with Unresolved reference 'doNotEndorseStrictVersions', and a cast to ModuleDependency fixes it. The Groovy DSL needs none:

Kotlin DSL
dependencies {
    implementation(platform(project(":strict-platform"))) {
        (this as ModuleDependency).doNotEndorseStrictVersions()
    }
    implementation("io.tidepool.demo:tides-chart:1.0.0")
}

tasks.register("resolve") {
    inputs.files(configurations.runtimeClasspath)
    doLast { println("resolved") }
}
Groovy DSL
dependencies {
    implementation(platform(project(':strict-platform'))) {
        doNotEndorseStrictVersions()
    }
    implementation 'io.tidepool.demo:tides-chart:1.0.0'
}

tasks.register('resolve') {
    inputs.files(configurations.runtimeClasspath)
    doLast { println 'resolved' }
}

The result is not the relaxed graph one might expect. The platform’s strict constraint is still there, but it is no longer promoted to the whole graph, and tides-chart's 1.2.0 is outside it, so resolution fails (the output below is from the Kotlin DSL run; the Groovy one differs only in the build file name on the first line):

FAILURE: Build failed with an exception.
* What went wrong:
Execution failed for task ':no-endorse:resolve' (registered in build file 'no-endorse/build.gradle.kts').
> Could not resolve all files for configuration ':no-endorse:runtimeClasspath'.
   > Could not resolve io.tidepool.demo:harmonics:{strictly [1.0, 1.2[; prefer 1.1.0}.
     Required by:
         project ':no-endorse' > project ':strict-platform'
      > Cannot find a version of 'io.tidepool.demo:harmonics' that satisfies the version constraints:
           Dependency path: project ':no-endorse' (runtimeClasspath) --> io.tidepool.demo:tides-chart:1.0.0 (runtime) --> 'io.tidepool.demo:harmonics:1.2.0'
           Constraint path: project ':no-endorse' (runtimeClasspath) --> project ':strict-platform' (runtimeElements) --> 'io.tidepool.demo:harmonics:{strictly [1.0, 1.2[; prefer 1.1.0}' because of the following reason: the platform pins the tested harmonics line
> There is 1 more failure with an identical cause.

Endorsement is therefore a property of the consumer’s relation to the platform, and disabling it turns a downgrade into a failure unless the dependency that asks for the out-of-range version is also changed.

Rich versions and constraints in published metadata

Whatever is written in a version { } block is published, in the format that can carry it. Gradle Module Metadata (the .module file, published next to the POM since Gradle 6.0) keeps all four terms; the POM keeps the highest of strictly and require (and a range as a Maven range) and drops prefer and reject. tides-kit is a library that gives its consumers room (a strict range with a preferred version), rejects one known-bad version of an internal dependency and constrains a third module:

Kotlin DSL
plugins { `java-library` }

dependencies {
    api("io.tidepool.demo:harmonics") {
        version {
            strictly("[1.0, 2.0[")
            prefer("1.1.0")
        }
        because("1.x keeps the Constituent API")
    }
    implementation("io.tidepool.demo:tides-api") {
        version {
            require("1.0.0")
            reject("1.0.1")
        }
    }
    constraints {
        implementation("io.tidepool.demo:tides-chart:1.0.0") {
            because("1.0.0 is the first release that renders tide tables")
        }
    }
}

publishing {
    publications {
        create<MavenPublication>("lib") {
            from(components["java"])
        }
    }
}

if (providers.gradleProperty("noGmm").isPresent) {
    tasks.withType<GenerateModuleMetadata>().configureEach { enabled = false }
}
Groovy DSL
plugins { id 'java-library' }

dependencies {
    api('io.tidepool.demo:harmonics') {
        version {
            strictly '[1.0, 2.0['
            prefer '1.1.0'
        }
        because '1.x keeps the Constituent API'
    }
    implementation('io.tidepool.demo:tides-api') {
        version {
            require '1.0.0'
            reject '1.0.1'
        }
    }
    constraints {
        implementation('io.tidepool.demo:tides-chart:1.0.0') {
            because '1.0.0 is the first release that renders tide tables'
        }
    }
}

publishing {
    publications {
        lib(MavenPublication) {
            from components.java
        }
    }
}

if (providers.gradleProperty('noGmm').present) {
    tasks.withType(GenerateModuleMetadata).configureEach { enabled = false }
}

The .module file keeps every statement (the files array, with the JAR’s name, size and checksums, is shown as ...):

{
  "formatVersion": "1.1",
  "component": {
    "group": "io.tidepool",
    "module": "tides-kit",
    "version": "1.0.0",
    "attributes": {
      "org.gradle.status": "release"
    }
  },
  "createdBy": {
    "gradle": {
      "version": "9.8.1"
    }
  },
  "variants": [
    {
      "name": "apiElements",
      "attributes": {
        "org.gradle.category": "library",
        "org.gradle.dependency.bundling": "external",
        "org.gradle.jvm.version": 26,
        "org.gradle.libraryelements": "jar",
        "org.gradle.usage": "java-api"
      },
      "dependencies": [
        {
          "group": "io.tidepool.demo",
          "module": "harmonics",
          "version": {
            "strictly": "[1.0, 2.0[",
            "requires": "[1.0, 2.0[",
            "prefers": "1.1.0"
          },
          "reason": "1.x keeps the Constituent API"
        }
      ],
      "files": [ ... ]
    },
    {
      "name": "runtimeElements",
      "attributes": {
        "org.gradle.category": "library",
        "org.gradle.dependency.bundling": "external",
        "org.gradle.jvm.version": 26,
        "org.gradle.libraryelements": "jar",
        "org.gradle.usage": "java-runtime"
      },
      "dependencies": [
        {
          "group": "io.tidepool.demo",
          "module": "tides-api",
          "version": {
            "requires": "1.0.0",
            "rejects": [
              "1.0.1"
            ]
          }
        },
        {
          "group": "io.tidepool.demo",
          "module": "harmonics",
          "version": {
            "strictly": "[1.0, 2.0[",
            "requires": "[1.0, 2.0[",
            "prefers": "1.1.0"
          },
          "reason": "1.x keeps the Constituent API"
        }
      ],
      "dependencyConstraints": [
        {
          "group": "io.tidepool.demo",
          "module": "tides-chart",
          "version": {
            "requires": "1.0.0"
          },
          "reason": "1.0.0 is the first release that renders tide tables"
        }
      ],
      "files": [ ... ]
    }
  ]
}

strictly, requires and prefers are all there for harmonics, rejects for tides-api, the reasons are carried over as reason, and the constraint is a dependencyConstraints entry of the runtime variant. The POM, generated at the same time, keeps less:

<?xml version="1.0" encoding="UTF-8"?>
<project xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd" xmlns="http://maven.apache.org/POM/4.0.0"
    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
  <!-- This module was also published with a richer model, Gradle metadata,  -->
  <!-- which should be used instead. Do not delete the following line which  -->
  <!-- is to indicate to Gradle or any Gradle module metadata file consumer  -->
  <!-- that they should prefer consuming it instead. -->
  <!-- do_not_remove: published-with-gradle-metadata -->
  <modelVersion>4.0.0</modelVersion>
  <groupId>io.tidepool</groupId>
  <artifactId>tides-kit</artifactId>
  <version>1.0.0</version>
  <dependencyManagement>
    <dependencies>
      <dependency>
        <groupId>io.tidepool.demo</groupId>
        <artifactId>tides-chart</artifactId>
        <version>1.0.0</version>
      </dependency>
    </dependencies>
  </dependencyManagement>
  <dependencies>
    <dependency>
      <groupId>io.tidepool.demo</groupId>
      <artifactId>harmonics</artifactId>
      <version>[1.0, 2.0)</version>
      <scope>compile</scope>
    </dependency>
    <dependency>
      <groupId>io.tidepool.demo</groupId>
      <artifactId>tides-api</artifactId>
      <version>1.0.0</version>
      <scope>runtime</scope>
    </dependency>
  </dependencies>
</project>

The range became [1.0, 2.0), the preferred version, the rejection and the reasons are gone, and the constraint became a dependencyManagement entry. To see what a consumer gets without the .module file, the same library was published as 1.0.0-pomonly with tasks.withType<GenerateModuleMetadata> { enabled = false }. Two consumers depend on a 2.0.0 of harmonics that the library does not support. With the Gradle metadata the build fails, and the message names the library’s statement and its reason:

Kotlin DSL
dependencies {
    implementation("io.tidepool:tides-kit:1.0.0")
    implementation("io.tidepool.demo:harmonics:2.0.0")
}

tasks.register("resolve") {
    inputs.files(configurations.runtimeClasspath)
    doLast { println("resolved") }
}
Groovy DSL
dependencies {
    implementation 'io.tidepool:tides-kit:1.0.0'
    implementation 'io.tidepool.demo:harmonics:2.0.0'
}

tasks.register('resolve') {
    inputs.files(configurations.runtimeClasspath)
    doLast { println 'resolved' }
}
FAILURE: Build failed with an exception.
* What went wrong:
Execution failed for task ':pub-kit-fail:resolve' (registered in build file 'pub-kit-fail/build.gradle.kts').
> Could not resolve all files for configuration ':pub-kit-fail:runtimeClasspath'.
   > Could not resolve io.tidepool.demo:harmonics:2.0.0.
     Required by:
         project ':pub-kit-fail'
      > Cannot find a version of 'io.tidepool.demo:harmonics' that satisfies the version constraints:
           Dependency path: project ':pub-kit-fail' (runtimeClasspath) --> 'io.tidepool.demo:harmonics:2.0.0'
           Dependency path: project ':pub-kit-fail' (runtimeClasspath) --> io.tidepool:tides-kit:1.0.0 (runtimeElements) --> 'io.tidepool.demo:harmonics:{strictly [1.0, 2.0[; prefer 1.1.0}' because of the following reason: 1.x keeps the Constituent API
           Dependency path: project ':pub-kit-fail' (runtimeClasspath) --> io.tidepool:tides-kit:1.0.0 (runtimeElements) --> io.tidepool.demo:tides-api:1.0.0 (runtime) --> 'io.tidepool.demo:harmonics:1.0.0'
> There are 2 more failures with identical causes.

With the POM only, the strict range is not there and the consumer silently gets 2.0.0:

Kotlin DSL
dependencies {
    implementation("io.tidepool:tides-kit:1.0.0-pomonly")
    implementation("io.tidepool.demo:harmonics:2.0.0")
}
Groovy DSL
dependencies {
    implementation 'io.tidepool:tides-kit:1.0.0-pomonly'
    implementation 'io.tidepool.demo:harmonics:2.0.0'
}
runtimeClasspath - Runtime classpath of source set 'main'.
+--- io.tidepool:tides-kit:1.0.0-pomonly
|    +--- io.tidepool.demo:harmonics:[1.0, 2.0) -> 2.0.0
|    \--- io.tidepool.demo:tides-api:1.0.0
|         \--- io.tidepool.demo:harmonics:1.0.0 -> 2.0.0
\--- io.tidepool.demo:harmonics:2.0.0

That is the practical meaning of "converting this information to Maven or Ivy metadata is lossy": a Gradle consumer sees the library’s whole intent, a Maven consumer sees a range. A consumer inside the range resolves normally: in the listing of the tides-kit consumer in "Constraints are transitive, and they are published" above, harmonics is 1.3.0. A library that needs both audiences keeps strictly ranges wide and always pairs them with a prefer.

Aligning versions

Modules that are released together must be used together. Gradle resolves each module on its own, so a graph in which two modules of one family are required at different versions can end up mixing them. In the scenario, suite-ui:1.1.0 needs suite-core:1.1.0 and suite-api:1.0.0 needs suite-core:1.0.0. Core is raised to 1.1.0 (the highest wins) but suite-api stays at 1.0.0, which was built against the older core:

Kotlin DSL
dependencies {
    implementation("io.tidepool.demo:suite-ui:1.1.0")
    implementation("io.tidepool.demo:suite-api:1.0.0")
}
Groovy DSL
dependencies {
    implementation 'io.tidepool.demo:suite-ui:1.1.0'
    implementation 'io.tidepool.demo:suite-api:1.0.0'
}
runtimeClasspath - Runtime classpath of source set 'main'.
+--- io.tidepool.demo:suite-ui:1.1.0
|    \--- io.tidepool.demo:suite-core:1.1.0
\--- io.tidepool.demo:suite-api:1.0.0
     \--- io.tidepool.demo:suite-core:1.0.0 -> 1.1.0

The manual offers three ways to make them agree. All three were run.

A published platform or BOM. If the family publishes one, import it. suite-bom:1.1.0 manages all three modules at 1.1.0, and suite-api:1.0.0 is raised to match:

Kotlin DSL
dependencies {
    implementation(platform("io.tidepool.demo:suite-bom:1.1.0"))
    implementation("io.tidepool.demo:suite-ui:1.1.0")
    implementation("io.tidepool.demo:suite-api:1.0.0")
}
Groovy DSL
dependencies {
    implementation platform('io.tidepool.demo:suite-bom:1.1.0')
    implementation 'io.tidepool.demo:suite-ui:1.1.0'
    implementation 'io.tidepool.demo:suite-api:1.0.0'
}
runtimeClasspath - Runtime classpath of source set 'main'.
+--- io.tidepool.demo:suite-bom:1.1.0
|    +--- io.tidepool.demo:suite-api:1.1.0 (c)
|    +--- io.tidepool.demo:suite-ui:1.1.0 (c)
|    \--- io.tidepool.demo:suite-core:1.1.0 (c)
+--- io.tidepool.demo:suite-ui:1.1.0
|    \--- io.tidepool.demo:suite-core:1.1.0
\--- io.tidepool.demo:suite-api:1.0.0 -> 1.1.0
     \--- io.tidepool.demo:suite-core:1.1.0

A virtual platform, when nobody published a BOM. A component metadata rule tells Gradle that the modules belong to the same virtual platform, belongsTo(...), and Gradle aligns every module of that platform to one version, the highest. The rule is a class with execute(ComponentMetadataContext), registered with components.all:

Kotlin DSL
abstract class SuiteAlignmentRule : ComponentMetadataRule {
    override fun execute(ctx: ComponentMetadataContext) {
        ctx.details.run {
            if (id.group == "io.tidepool.demo" && id.name.startsWith("suite-")) {
                belongsTo("io.tidepool.demo:suite-virtual-platform:${id.version}")
            }
        }
    }
}

dependencies {
    implementation("io.tidepool.demo:suite-ui:1.1.0")
    implementation("io.tidepool.demo:suite-api:1.0.0")
    components.all<SuiteAlignmentRule>()
}
Groovy DSL
abstract class SuiteAlignmentRule implements ComponentMetadataRule {
    void execute(ComponentMetadataContext ctx) {
        def id = ctx.details.id
        if (id.group == 'io.tidepool.demo' && id.name.startsWith('suite-')) {
            ctx.details.belongsTo("io.tidepool.demo:suite-virtual-platform:${id.version}")
        }
    }
}

dependencies {
    implementation 'io.tidepool.demo:suite-ui:1.1.0'
    implementation 'io.tidepool.demo:suite-api:1.0.0'
    components.all(SuiteAlignmentRule)
}
runtimeClasspath - Runtime classpath of source set 'main'.
+--- io.tidepool.demo:suite-ui:1.1.0
|    \--- io.tidepool.demo:suite-core:1.1.0
\--- io.tidepool.demo:suite-api:1.0.0 -> 1.1.0
     \--- io.tidepool.demo:suite-core:1.1.0
./gradlew :align-rule:dependencyInsight --dependency suite-api --configuration runtimeClasspath
io.tidepool.demo:suite-api:1.1.0
  Variant runtime:
    ...
   Selection reasons:
      - By constraint: belongs to platform io.tidepool.demo:suite-virtual-platform:1.1.0
      - By conflict resolution: between versions 1.1.0 and 1.0.0
io.tidepool.demo:suite-api:1.0.0 -> 1.1.0
\--- runtimeClasspath

The selection reason is the platform: By constraint: belongs to platform io.tidepool.demo:suite-virtual-platform:1.1.0. The rule was run in both DSLs, with a difference worth knowing. The manual’s Groovy form wraps the body in ctx.details.run { ... }, which is Kotlin’s standard-library run; the details object has no such method in Groovy. In the Groovy DSL of Gradle 9.8.1 it fails when the rule is evaluated, so the Groovy listing above reads ctx.details.id and calls belongsTo on ctx.details instead. This is what the manual’s form produced (Kotlin DSL does not hit it):

io.tidepool.demo:suite-api:1.0.0 FAILED
   Failures:
      - Could not resolve io.tidepool.demo:suite-api:1.0.0.
          - Could not resolve io.tidepool.demo:suite-api:1.0.0.
              - There was an error while evaluating a component metadata rule for io.tidepool.demo:suite-api:1.0.0.
                  - Could not find method run() for arguments [SuiteAlignmentRule$_execute_closure1@395db8b6] on io.tidepool.demo:suite-api:1.0.0 of type org.gradle.api.internal.artifacts.repositories.resolver.ComponentMetadataDetailsAdapter_Decorated.
io.tidepool.demo:suite-api:1.0.0 FAILED
\--- runtimeClasspath

The java-platform plugin, for modules you publish. Section "Building a platform" above did this already: tidepool-model and tidepool-engine each declare api(platform(project(":tidepool-platform"))), and the platform constrains both. The consumer asks for tidepool-model:1.1.0 and tidepool-engine:1.0.0; the model’s platform (1.1.0) constrains the engine to 1.1.0, and the engine that was asked for at 1.0.0 is raised without the consumer saying anything:

Kotlin DSL
dependencies {
    implementation("io.tidepool:tidepool-model:1.1.0")
    implementation("io.tidepool:tidepool-engine:1.0.0")
}
Groovy DSL
dependencies {
    implementation 'io.tidepool:tidepool-model:1.1.0'
    implementation 'io.tidepool:tidepool-engine:1.0.0'
}
runtimeClasspath - Runtime classpath of source set 'main'.
+--- io.tidepool:tidepool-model:1.1.0
|    \--- io.tidepool:tidepool-platform:1.1.0
|         +--- io.tidepool:tidepool-model:1.1.0 (c)
|         +--- io.tidepool:tidepool-engine:1.1.0 (c)
|         \--- org.apache.commons:commons-csv:1.14.1 (c)
\--- io.tidepool:tidepool-engine:1.0.0 -> 1.1.0
     +--- org.apache.commons:commons-csv -> 1.14.1
     |    +--- commons-io:commons-io:2.20.0
     |    \--- commons-codec:commons-codec:1.19.0
     +--- io.tidepool:tidepool-platform:1.1.0 (*)
     \--- io.tidepool:tidepool-model:1.1.0 (*)

The three solutions differ in who has to act. A BOM needs the consumer to import it; a virtual platform needs the consumer to register a rule; the java-platform platform needs the publisher to do the work once, and consumers get alignment for free. Maven has no alignment concept, only the Enforcer rules dependencyConvergence and requireUpperBoundDeps that fail a build whose graph is not aligned (see Dependency Analysis & Convergence).

Preventing accidental upgrades

Gradle upgrades optimistically: if two parts of the graph want different versions of a module, the highest wins, even above what your own build declared. That is the right default for a library ecosystem and the wrong one when you depend on an API that a newer version removed. The manual names three ways to say "not this time", and they differ in how loud they are.

Approach What happens when a higher version is requested Run on this page

resolutionStrategy.failOnVersionConflict()

The build fails and names the module and the two versions.

Yes, below.

A strictly (in a constraint, so it only applies when the module is present)

The higher requirement is overridden and the strict version is used; the build keeps working.

Yes, "A bare version in a constraint does not cap anything" above.

Dependency locking (activateDependencyLocking() and a gradle.lockfile)

The versions resolved at lock time are reused until someone updates the lock file.

No, see the locking page.

The loud one: failOnVersionConflict turns every conflict into an error, so that no version is upgraded without a person looking at it. The project asks for harmonics:1.0.0 and also uses tides-api:1.1.0, which needs 1.1.0 (the helper task is the same resolve task as before, and the run is without the configuration cache because it fails):

Kotlin DSL
dependencies {
    implementation("io.tidepool.demo:harmonics:1.0.0")
    implementation("io.tidepool.demo:tides-api:1.1.0")
}

configurations.configureEach {
    resolutionStrategy.failOnVersionConflict()
}

tasks.register("resolve") {
    inputs.files(configurations.runtimeClasspath)
    doLast { println("resolved") }
}
Groovy DSL
dependencies {
    implementation 'io.tidepool.demo:harmonics:1.0.0'
    implementation 'io.tidepool.demo:tides-api:1.1.0'
}

configurations.configureEach {
    resolutionStrategy.failOnVersionConflict()
}

tasks.register('resolve') {
    inputs.files(configurations.runtimeClasspath)
    doLast { println 'resolved' }
}
FAILURE: Build failed with an exception.
* What went wrong:
Execution failed for task ':fail-on-conflict:resolve' (registered in build file 'fail-on-conflict/build.gradle.kts').
> Could not resolve all files for configuration ':fail-on-conflict:runtimeClasspath'.
   > Could not resolve io.tidepool.demo:harmonics:1.0.0.
     Required by:
         project ':fail-on-conflict'
      > Conflict found for module 'io.tidepool.demo:harmonics': between versions 1.1.0 and 1.0.0
> There is 1 more failure with an identical cause.

Everything that is not a conflict still resolves; every conflict must be settled by a person, with a strictly, a constraint or a newer declaration. That is a good setting for a build that is under dependency review and a heavy one for a large graph, where most conflicts are harmless; locking is the quiet alternative, and it is the one that makes dynamic versions reproducible, on Dependency Locking, Verification & Caching. The blunt tools, force and eachDependency, are on Resolution Rules & Substitution.

The same statements in Maven and Ivy

Intent Maven Ivy Gradle

"At least this version"

A plain <version> is a soft requirement, nearest definition wins

A plain rev under the default latest-revision manager

require (a plain version)

"Exactly this version, or fail"

A hard requirement [1.0]; a dependencyManagement entry pins a transitive one

force="true" on the dependency, or the strict conflict manager

strictly (!!)

Raise or pin a transitive dependency

dependencyManagement

<override> or force

A constraint, or strictly in a constraint

Never use this version

A range with a hole, (,1.4),(1.4,), or an Enforcer banned-dependency rule

An <override> to another revision

reject

Import a family of versions

A BOM with <scope>import</scope>

The POM parser expands it to <override>

platform(), enforcedPlatform()

Make a family agree

A BOM; Enforcer dependencyConvergence reports disagreement but does not fix it

No equivalent

Alignment through platforms and belongsTo

Releases, Gradle 8.14 and where the books are outdated

The mechanisms on this page arrived over several releases, each from its release notes.

Release What changed

4.6

Dependency constraints for transitive dependencies; importing BOMs and compile/runtime separation when consuming POMs, as a feature preview that had to be enabled in the settings file.

5.0

Dependency version alignment, usable with Maven BOMs; the release notes show platform() and enforcedPlatform() for importing a BOM.

5.3

Fixes a bug of 5.0 to 5.2.1 in which an enforcedPlatform import of a POM that had both dependencies and dependencyManagement included the dependencies instead of only the constraints; platform and enforcedPlatform dependencies no longer try to download JARs.

6.0

Gradle Module Metadata is published by default; rich versions (strictly, prefer, reject), constraints and alignment are stable and production-ready.

7.0

The compile, runtime and testCompile configurations and the maven plugin are removed; version catalogs appear as a preview (stable in 7.4).

9.0

jcenter() is removed and Gradle runs on JVM 17 and later.

9.8

Reuse of Maven mirror settings (org.gradle.mirror.maven.settings=true), relevant to where BOMs are downloaded from.

Gradle 8.14. Gradle 8.14 was not available here, so everything on this page was run on 9.8.1 only. The features (version { }, constraints { }, platform(), enforcedPlatform(), java-platform, belongsTo, doNotEndorseStrictVersions()) date from 6.0 or earlier according to the release notes above and are expected to work the same way in 8.14, but that, the wording of the version-ordering and competing-strictly rules in the 8.14 manual, and the dependencyScope/resolvable factories used by the probeVersions helper were not checked (not run here). The 9.0 changes that touch dependency declarations are removals (see Versions, Compatibility & Release History).

Gradle in Action (Gradle 1.7). The book predates platforms, constraints, rich versions and alignment. It keeps versions in ext properties, where the current tool is a version catalog (stable in 7.4), and it declares dependencies with the compile and testCompile configurations, removed in Gradle 7.0 in favour of implementation, api and testImplementation. Publisher page for the book: Gradle in Action, Manning.

Pitfalls

Pitfall What to do

A bare version in constraints { } does not cap a version

It is a require. Use version { strictly(...) } inside the constraint.

strictly plus prefer selects the preferred version, not the highest in the range

Intentional: a direct strictly overrides the transitive requirements. Drop the prefer to get the highest.

(1.0, 1.3] does not include 1.3.0

1.3 is older than 1.3.0. Write the full version in the bound.

A strictly in a dependency’s constraints is dropped when the root project is strict about the same module

Root-most wins. Put the statement in the root project, or accept that the dependency’s is ignored.

enforcedPlatform in a library

Its strictness is published and spreads to consumers. Use it only in applications.

doNotEndorseStrictVersions() fails the build

The platform’s strict constraint is no longer applied to other dependencies' requests, but it still stands. Change the dependency that asks for the out-of-range version, or the platform.

The manual’s Kotlin snippet for doNotEndorseStrictVersions() does not compile

The lambda of platform(...) is typed Dependency; cast to ModuleDependency.

The manual’s Groovy ctx.details.run { } rule fails in Gradle 9.8.1

Read ctx.details.id and call belongsTo on ctx.details instead.

Dynamic versions are cached for 24 hours

cacheDynamicVersionsFor for a build that must see new releases at once; locking for builds that must not.

A POM-only consumer loses strictly, prefer and reject

Publish Gradle Module Metadata (the default) and keep the strict ranges wide.