Dependency Management Fundamentals
|
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 Apache Ant 1.10.18 with Apache Ivy 2.6.0 and the Maven Resolver Ant Tasks 2.0.0, Apache Maven 3.10.0 (3.9.16 and Maven 4.0.0-rc-7, a release candidate, are named where they differ) and Gradle 9.8.1, and every example was run on JDK 26 (checked on 2026-10-16). The examples that print a result were run with exactly those releases from a scratch directory; an example that was not run is labelled "not run here".
A dependency is a piece of software your build needs that somebody else produced: a library to compile against, a library to run with, a test framework, a compiler plugin. Dependency management is everything a build tool does about that: it names the piece (the coordinates), finds it in a repository, finds what it needs in turn (the transitive dependencies), decides which one of two competing versions wins, puts the right files on the right classpath, and ideally makes the answer the same tomorrow as it is today. Ant itself does none of this — it copies files you point it to — so the Ant world borrows the machinery from Apache Ivy or from the Maven Resolver Ant Tasks. Maven and Gradle have it built in, and the three tools turn out to share more than they disagree on: all of them read the same Maven repository layout and the same POM files, and almost all the real differences are in two places, the conflict policy and how they let you pin versions.
This page is the shared vocabulary. The running scenario is the tidepool toolkit described in
the section landing page. The examples take a small slice of the
tidepool-cli dependency graph and run it through every tool. The details of each tool live on its own pages, linked from
Where to go next; how the repositories are laid out and published to is in
Repositories & Publishing Across Tools;
the build-graph side (targets, phases, tasks) is in
Build Automation Fundamentals.
Coordinates: naming a piece of software
Every tool needs a name for a dependency that is unique across all repositories. They all converge on the Maven idea of coordinates, with different spellings.
| Tool | Spelling | Elements |
|---|---|---|
Maven |
|
|
Gradle |
|
|
Ivy (and so Ant with Ivy) |
|
|
Maven Resolver Ant Tasks |
|
the same as Maven; it reads and writes Maven repositories |
Three distinctions are worth fixing at once, because the three tools mix them up in different ways.
-
A module (Ivy), component (Gradle) or project (Maven) is the unit that has a version and a descriptor. An artifact is one file that the module publishes: the main JAR, a
-sources.jar, a-javadoc.jar, a.pom. A module with one version publishes one descriptor and any number of artifacts. A dependency is declared on a module, and the build then picks which of its artifacts it wants. -
The classifier is a suffix that tells two artifacts of the same module apart:
commons-io-2.20.0.jarandcommons-io-2.20.0-sources.jar. The type in Maven or the extension in Gradle is the file extension. Maven maps atypeto an extension (and sometimes a classifier) through anArtifactHandler; Gradle writes the extension after an@. -
A module has one version in a given graph. That sounds obvious, and it is the root of the whole conflict problem below: two paths through the graph can ask for two different versions of the same
group:name, and the tool has to keep only one.
The same file is named in the three tools. The example resolves only the sources artifact of commons-io 2.20.0 and
prints the file it got.
mvn org.apache.maven.plugins:maven-dependency-plugin:3.11.0:copy -Dartifact=commons-io:commons-io:2.20.0:jar:sources -DoutputDirectory=out
commons-io-2.20.0-sources.jar
repositories {
mavenCentral()
}
val sourcesOnly: Configuration by configurations.creating {
isTransitive = false
}
dependencies {
// group:name:version:classifier@extension
sourcesOnly("commons-io:commons-io:2.20.0:sources@jar")
}
tasks.register("showCoordinates") {
val files = sourcesOnly.incoming.files
doLast {
files.forEach { println(it.name) }
}
}
repositories {
mavenCentral()
}
configurations {
sourcesOnly {
transitive = false
}
}
dependencies {
// group:name:version:classifier@extension
sourcesOnly 'commons-io:commons-io:2.20.0:sources@jar'
}
tasks.register('showCoordinates') {
def files = configurations.sourcesOnly.incoming.files
doLast {
files.each { println it.name }
}
}
Running gradle -q showCoordinates with either file printed the same line.
commons-io-2.20.0-sources.jar
Direct and transitive dependencies, and the graph
A direct dependency is one you declare. A transitive dependency is one that a direct dependency (or one of its own
dependencies) needs. The tools learn about transitive dependencies by reading the descriptor each dependency published
next to its JAR — a POM for a Maven repository, an ivy.xml for an Ivy one, a .module file for a Gradle one (see
Module metadata). There is no depth limit; only a cycle is
an error. The result is a graph, and it grows fast: a project with ten direct dependencies routinely resolves a few
dozen modules.
The slice of tidepool-cli used below is deliberately tiny. tidepool-cli depends on commons-csv 1.14.1, which in turn
needs commons-io 2.20.0 and commons-codec 1.19.0. To make a conflict, tidepool-cli also declares commons-io
2.11.0 directly, as a project often does for a library it uses itself. Two different versions of one module are now
requested, and the tools disagree about the winner.
Maven prints that graph with dependency:tree. -Dverbose keeps the nodes the resolver dropped, which is exactly what
you want when a version is not the one you expected.
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>io.tidepool</groupId>
<artifactId>tidepool-cli</artifactId>
<version>1.0.0-SNAPSHOT</version>
<dependencies>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-csv</artifactId>
<version>1.14.1</version>
</dependency>
<dependency>
<groupId>commons-io</groupId>
<artifactId>commons-io</artifactId>
<version>2.11.0</version>
</dependency>
</dependencies>
</project>
mvn -B org.apache.maven.plugins:maven-dependency-plugin:3.11.0:tree -Dverbose
[INFO] --- dependency:3.11.0:tree (default-cli) @ tidepool-cli ---
[INFO] io.tidepool:tidepool-cli:jar:1.0.0-SNAPSHOT
[INFO] +- org.apache.commons:commons-csv:jar:1.14.1:compile
[INFO] | +- (commons-io:commons-io:jar:2.20.0:compile - omitted for conflict with 2.11.0)
[INFO] | \- commons-codec:commons-codec:jar:1.19.0:compile
[INFO] \- commons-io:commons-io:jar:2.11.0:compile
Gradle prints the same graph with the dependencies task, and dependencyInsight explains one module. The arrow ->
means "requested on the left, selected on the right".
plugins {
`java-library`
}
repositories {
mavenCentral()
}
dependencies {
implementation("org.apache.commons:commons-csv:1.14.1")
implementation("commons-io:commons-io:2.11.0")
}
plugins {
id 'java-library'
}
repositories {
mavenCentral()
}
dependencies {
implementation 'org.apache.commons:commons-csv:1.14.1'
implementation 'commons-io:commons-io:2.11.0'
}
gradle -q dependencies --configuration runtimeClasspath
gradle -q dependencyInsight --dependency commons-io --configuration runtimeClasspath
runtimeClasspath - Runtime classpath of source set 'main'.
+--- org.apache.commons:commons-csv:1.14.1
| +--- commons-io:commons-io:2.20.0
| \--- commons-codec:commons-codec:1.19.0
\--- commons-io:commons-io:2.11.0 -> 2.20.0
commons-io:commons-io:2.20.0
Variant runtime:
| Attribute Name | Provided | Requested |
|--------------------------------|--------------|--------------|
| org.gradle.status | release | |
| org.gradle.category | library | library |
| org.gradle.libraryelements | jar | jar |
| org.gradle.usage | java-runtime | java-runtime |
| org.gradle.dependency.bundling | | external |
| org.gradle.jvm.environment | | standard-jvm |
| org.gradle.jvm.version | | 26 |
Selection reasons:
- By conflict resolution: between versions 2.20.0 and 2.11.0
commons-io:commons-io:2.20.0
\--- org.apache.commons:commons-csv:1.14.1
commons-io:commons-io:2.11.0 -> 2.20.0
\--- runtimeClasspath
The Gradle output answered the question "why 2.20.0?" directly: by conflict resolution. The last two blocks are the two requests, and each is shown with who made it.
Source: Gradle: Viewing Dependencies, listing dependencies and Gradle: Viewing Dependencies, identifying which dependency version was selected and why
Ant with Ivy resolves a graph from an ivy.xml and prints the selected modules; Ant with the Maven Resolver Ant Tasks
resolves from the dependencies written in the build file. Both examples print the resulting runtime classpath as plain
JAR names. The Ivy descriptor declares two configurations, compile and runtime, which is the Ivy counterpart of
Maven scopes (see the next section).
<ivy-module version="2.0">
<info organisation="io.tidepool" module="tidepool-cli" revision="1.0.0"/>
<configurations>
<conf name="compile" description="needed to compile"/>
<conf name="runtime" extends="compile" description="needed to run"/>
</configurations>
<dependencies>
<dependency org="org.apache.commons" name="commons-csv" rev="1.14.1" conf="compile->default"/>
<dependency org="commons-io" name="commons-io" rev="2.11.0" conf="compile->default"/>
</dependencies>
</ivy-module>
<ivysettings>
<settings defaultResolver="central"/>
<caches defaultCacheDir="${basedir}/build/ivy-cache"/>
<resolvers>
<ibiblio name="central" m2compatible="true"/>
</resolvers>
</ivysettings>
<project name="tidepool-cli" default="resolve" xmlns:ivy="antlib:org.apache.ivy.ant"
xmlns:resolver="antlib:org.apache.maven.resolver.ant">
<path id="tooling.path">
<fileset dir="lib" includes="*.jar"/>
</path>
<taskdef uri="antlib:org.apache.ivy.ant" resource="org/apache/ivy/ant/antlib.xml" classpathref="tooling.path"/>
<taskdef uri="antlib:org.apache.maven.resolver.ant" resource="org/apache/maven/resolver/ant/antlib.xml" classpathref="tooling.path"/>
<target name="resolve" description="Resolve with Ivy (latest-revision, the default)">
<ivy:settings file="ivysettings.xml"/>
<ivy:resolve conf="runtime" log="download-only"/>
<ivy:cachepath conf="runtime" pathid="ivy.runtime.path"/>
<pathconvert property="ivy.runtime.names" refid="ivy.runtime.path" pathsep="${line.separator}">
<mapper type="flatten"/>
</pathconvert>
<echo message="Ivy runtime classpath:${line.separator}${ivy.runtime.names}"/>
</target>
<target name="resolve-maven" description="Resolve with the Maven Resolver Ant Tasks (nearest wins)">
<resolver:resolve>
<dependencies>
<dependency coords="org.apache.commons:commons-csv:1.14.1"/>
<dependency coords="commons-io:commons-io:2.11.0"/>
</dependencies>
<path refid="maven.runtime.path" classpath="runtime"/>
</resolver:resolve>
<pathconvert property="maven.runtime.names" refid="maven.runtime.path" pathsep="${line.separator}">
<mapper type="flatten"/>
</pathconvert>
<echo message="Resolver Ant Tasks runtime classpath:${line.separator}${maven.runtime.names}"/>
</target>
</project>
The two JARs the tasks need (ivy-2.6.0.jar and maven-resolver-ant-tasks-2.0.0-uber.jar) were put in lib/ first; how
to install them is on Ivy Overview and
Maven Resolver Ant Tasks. The runs below were
made on a clean Ivy cache; the <dir> placeholder replaces the scratch directory path.
ant resolve
Buildfile: <dir>/build.xml
resolve:
[ivy:resolve] :: Apache Ivy 2.6.0 - 20260712075753 :: https://ant.apache.org/ivy/ ::
[ivy:resolve] :: loading settings :: file = <dir>/ivysettings.xml
[ivy:resolve] downloading https://repo1.maven.org/maven2/org/apache/commons/commons-csv/1.14.1/commons-csv-1.14.1.jar ...
[ivy:resolve] ............... (59kB)
[ivy:resolve] .. (0kB)
[ivy:resolve] [SUCCESSFUL ] org.apache.commons#commons-csv;1.14.1!commons-csv.jar (131ms)
[ivy:resolve] downloading https://repo1.maven.org/maven2/commons-io/commons-io/2.20.0/commons-io-2.20.0.jar ...
[ivy:resolve] .................................... (550kB)
[ivy:resolve] .. (0kB)
[ivy:resolve] [SUCCESSFUL ] commons-io#commons-io;2.20.0!commons-io.jar (154ms)
[ivy:resolve] downloading https://repo1.maven.org/maven2/commons-codec/commons-codec/1.19.0/commons-codec-1.19.0.jar ...
[ivy:resolve] .......................... (365kB)
[ivy:resolve] .. (0kB)
[ivy:resolve] [SUCCESSFUL ] commons-codec#commons-codec;1.19.0!commons-codec.jar (126ms)
[echo] Ivy runtime classpath:
[echo] commons-csv-1.14.1.jar
[echo] commons-io-2.20.0.jar
[echo] commons-codec-1.19.0.jar
BUILD SUCCESSFUL
Total time: 2 seconds
ant resolve-maven
Buildfile: <dir>/build.xml
resolve-maven:
[resolver:resolve] Resolving artifacts
[echo] Resolver Ant Tasks runtime classpath:
[echo] commons-csv-1.14.1.jar
[echo] commons-codec-1.19.0.jar
[echo] commons-io-2.11.0.jar
BUILD SUCCESSFUL
Total time: 0 seconds
Ivy kept commons-io 2.20.0 and the Resolver Ant Tasks kept 2.11.0. The next-but-one section explains why.
The three habits that keep a graph healthy are the same in every tool. Declare every library your own source code imports directly, even if it also arrives transitively, because the transitive path is somebody else’s implementation detail and can change. Look at the resolved graph whenever a dependency changes. And pin or constrain a version only with a reason you can write down.
Scopes and configurations
Not every dependency belongs on every classpath. A compiler plugin should not ship; a test framework should not be needed by a consumer; a servlet API is provided by the container. The tools express "which classpath, and does it travel to my consumers" differently.
-
Maven calls it a scope on each dependency. A scope fixes which of the classpaths (compile, runtime, test) the dependency joins, and whether it is passed on transitively and in which scope.
-
Ivy calls it a configuration (
conf) of the module, and a dependency maps one of my configurations to one of the dependency’s configurations:compile->defaultreads "mycompileneeds the dependency’sdefault". -
Gradle also calls it a configuration, but a configuration is a named bucket of dependencies that is declared, resolved or consumed; the Java plugins create
implementation,api,runtimeOnly,compileOnly,testImplementationand others for you.
| Maven scope | Gradle (java-library) |
Ivy configuration (a convention) | Meaning |
|---|---|---|---|
|
|
|
On every classpath; Maven passes it on to consumers. Gradle’s |
|
|
|
On the compile classpath only; supplied by the JDK or a container at run time; not transitive. |
|
|
|
Needed to run, not to compile. |
|
|
|
Only for compiling and running tests; not transitive. |
|
a file dependency, |
none; a file-system resolver can serve a local directory |
A JAR at a fixed path that is not looked up in a repository. |
|
|
|
Copies version information from a BOM; adds no dependency by itself. |
Source: Maven: Dependency Scope, Gradle: Java Library plugin, API and implementation separation and Ivy: configurations
Two points matter in practice. First, the Ivy names are a convention, not a rule: a conf can be called anything, and you
choose which ones extend which (in the descriptor above runtime extends compile, so a runtime classpath contains
everything the compile classpath does). Second, the three vocabularies have a faithful translation at the boundary where a
module is published, because a Maven consumer reads a POM, not a Gradle build file. Publishing a java-library project
with Gradle shows the translation. The project declares
plugins {
`java-library`
`maven-publish`
}
group = "io.tidepool"
version = "1.0.0"
repositories {
mavenCentral()
}
dependencies {
api("org.apache.commons:commons-csv:1.14.1")
implementation("info.picocli:picocli:4.7.7")
compileOnly("org.jspecify:jspecify:1.0.0")
runtimeOnly("commons-io:commons-io:2.20.0")
testImplementation("org.junit.jupiter:junit-jupiter:6.1.3")
}
publishing {
publications {
create<MavenPublication>("maven") {
from(components["java"])
}
}
repositories {
maven {
name = "local"
url = uri(layout.buildDirectory.dir("repo"))
}
}
}
plugins {
id 'java-library'
id 'maven-publish'
}
group = 'io.tidepool'
version = '1.0.0'
repositories {
mavenCentral()
}
dependencies {
api 'org.apache.commons:commons-csv:1.14.1'
implementation 'info.picocli:picocli:4.7.7'
compileOnly 'org.jspecify:jspecify:1.0.0'
runtimeOnly 'commons-io:commons-io:2.20.0'
testImplementation 'org.junit.jupiter:junit-jupiter:6.1.3'
}
publishing {
publications {
create('maven', MavenPublication) {
from components.java
}
}
repositories {
maven {
name = 'local'
url = layout.buildDirectory.dir('repo')
}
}
}
and gradle publishMavenPublicationToLocalRepository wrote a file repository under build/repo. The dependency section of
the generated POM, which is what a Maven or Ant consumer reads, was
<dependencies>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-csv</artifactId>
<version>1.14.1</version>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>info.picocli</groupId>
<artifactId>picocli</artifactId>
<version>4.7.7</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>commons-io</groupId>
<artifactId>commons-io</artifactId>
<version>2.20.0</version>
<scope>runtime</scope>
</dependency>
</dependencies>
api became compile, implementation and runtimeOnly both became runtime, and compileOnly and testImplementation
were not published at all, because they never concern a consumer. The api/implementation distinction therefore survives
only for Gradle consumers, which read the .module file written next to the POM (see
Module metadata).
The translation goes the other way too. When Ivy reads a Maven POM, it builds a module descriptor whose configurations are
the Maven scopes. This is the <configurations> element of the descriptor Ivy wrote for commons-csv 1.14.1 (the
descriptions are shortened), taken from the Ivy cache after the Ant resolve run above:
<configurations>
<conf name="default" visibility="public" extends="runtime,master"/>
<conf name="master" visibility="public" description="contains only the artifact published by this module itself, with no transitive dependencies"/>
<conf name="compile" visibility="public"/>
<conf name="provided" visibility="public"/>
<conf name="runtime" visibility="public" extends="compile"/>
<conf name="test" visibility="public" extends="runtime"/>
<conf name="system" visibility="public"/>
<conf name="sources" visibility="public"/>
<conf name="javadoc" visibility="public"/>
<conf name="optional" visibility="public"/>
</configurations>
That is why compile->default in the tidepool-cli descriptor works against a module that has no ivy.xml of its own:
default is the configuration that extends runtime and master.
Exclusions and optional dependencies are the other ways to cut a graph: Maven has <exclusions> and <optional>,
Gradle has exclude(group = ..., module = ...) and isTransitive = false, Ivy has <exclude> and per-dependency
transitive="false". They are listed here so the vocabulary is complete; they are not run on this page.
Conflict resolution
When two paths of the graph request different versions of the same module, a tool must keep one. The three policies are easy to state and have different failure modes.
| Tool | Policy | What it means for tidepool-cli |
|---|---|---|
Maven (and Maven Resolver Ant Tasks) |
"Nearest definition": the version closest to the root of the graph wins; if two are at the same depth, the first declaration wins |
|
Gradle |
The highest of all requested versions wins, wherever it appears in the graph, unless a constraint or a |
|
Ivy |
A conflict manager, per module or for all: |
2.20.0 by default; 2.11.0 with |
Source: Maven: transitive dependencies, dependency mediation, Gradle: Resolving version conflicts and Ivy: conflict managers
Each policy has a pitfall that the others do not have.
-
Nearest definition looks only at the shape of the graph. It never compares versions, so a direct dependency on an older version silently downgrades a library that needs a newer one, as the
tidepool-cligraph shows:commons-csv1.14.1 was built againstcommons-io2.20.0 and now runs with 2.11.0. Nothing fails until a method that does not exist in 2.11.0 is called. The-Dverbosetree above is how you see it. -
Highest version looks only at the numbers. A higher version is not always a compatible one, and the winning version can come from a dependency four levels down that you never looked at.
-
Ivy’s
latest-revisionis "latest" by a string-oriented comparison of revisions (the documentation points out thatlatest-timeis more costly to compute). Ivy also honours theforceattribute of a direct dependency under the twolatestmanagers.
Maven: nearest definition, and what dependencyManagement does and does not change
Maven’s way out of a bad mediation is to take the decision yourself. A dependencyManagement entry fixes the version of a
dependency wherever it appears, but only where the version is not already written down: a direct dependency that carries
its own <version> keeps it. Both effects were run. With the same two dependencies as before, a managed commons-io
2.20.0 and the direct declaration still pinned to 2.11.0, the direct version won, and the transitive request, although
managed to 2.20.0, still lost to it:
<dependencyManagement>
<dependencies>
<dependency>
<groupId>commons-io</groupId>
<artifactId>commons-io</artifactId>
<version>2.20.0</version>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-csv</artifactId>
<version>1.14.1</version>
</dependency>
<dependency>
<groupId>commons-io</groupId>
<artifactId>commons-io</artifactId>
<version>2.11.0</version>
</dependency>
</dependencies>
[INFO] +- org.apache.commons:commons-csv:jar:1.14.1:compile
[INFO] | +- (commons-io:commons-io:jar:2.20.0:compile - version managed from 2.20.0; omitted for conflict with 2.11.0)
[INFO] | \- commons-codec:commons-codec:jar:1.19.0:compile
[INFO] +- commons-io:commons-io:jar:2.11.0:compile
Removing the <version> from the direct declaration (the managed one applies to it) gives the pin that was intended:
<dependencies>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-csv</artifactId>
<version>1.14.1</version>
</dependency>
<dependency>
<groupId>commons-io</groupId>
<artifactId>commons-io</artifactId>
</dependency>
</dependencies>
[INFO] +- org.apache.commons:commons-csv:jar:1.14.1:compile
[INFO] | +- (commons-io:commons-io:jar:2.20.0:compile - version managed from 2.20.0; omitted for duplicate)
[INFO] | \- commons-codec:commons-codec:jar:1.19.0:compile
[INFO] \- commons-io:commons-io:jar:2.20.0:compile
Maven 3.10.0 changed one related thing: the dependency classpath handed to the compiler and the tests is now ordered
breadth first ("level-order") instead of depth first ("pre-order"), matching Maven 4; -Daether.system.dependencyVisitor=preOrder
restores the old order, according to the
Maven 3.10.0 release notes. On the small tidepool-cli graph the
printed compiler classpath was the same with Maven 3.10.0, 3.9.16 and 4.0.0-rc-7 (commons-csv, then commons-codec, then
commons-io), so this graph does not show the difference; a graph with two branches of different depth does. The
dependency:tree output of Maven 4.0.0-rc-7 (a release candidate) on this graph has the same selection as above,
commons-io 2.11.0.
Gradle: highest wins, and how to say otherwise
The plain build above selected 2.20.0 without a word from you. When the highest version is wrong, or when you want
a floor under it, Gradle has three tools, each stronger than the last: a constraint (a floor that adds no dependency), a
rich version with strictly (a ceiling or an exact version that fails the build if somebody asks for something
outside it), and forcing via resolution rules, which belongs to the dedicated pages. A constraint on commons-io:
dependencies {
implementation("org.apache.commons:commons-csv:1.14.1")
implementation("commons-io:commons-io:2.11.0")
constraints {
implementation("commons-io:commons-io:2.20.0") {
because("commons-csv 1.14.1 was built against 2.20.0")
}
}
}
dependencies {
implementation 'org.apache.commons:commons-csv:1.14.1'
implementation 'commons-io:commons-io:2.11.0'
constraints {
implementation('commons-io:commons-io:2.20.0') {
because 'commons-csv 1.14.1 was built against 2.20.0'
}
}
}
runtimeClasspath - Runtime classpath of source set 'main'.
+--- org.apache.commons:commons-csv:1.14.1
| +--- commons-io:commons-io:2.20.0
| \--- commons-codec:commons-codec:1.19.0
+--- commons-io:commons-io:2.11.0 -> 2.20.0
\--- commons-io:commons-io:2.20.0 (c)
(c) - A dependency constraint, not a dependency. The dependency affected by the constraint occurs elsewhere in the tree.
A constraint can only raise the result, never lower it, because the winner is still the highest. To force the older
version (which is what Maven did without being asked) the version must be strict. A range with a strictly bound and a
prefer is read as "anything in [2.11.0, 2.15[, and if nothing else decides, 2.11.0", and the strict bound beats the
higher version that commons-csv asked for:
dependencies {
implementation("org.apache.commons:commons-csv:1.14.1")
implementation("commons-io:commons-io") {
version {
strictly("[2.11.0, 2.15[")
prefer("2.11.0")
}
}
}
dependencies {
implementation 'org.apache.commons:commons-csv:1.14.1'
implementation('commons-io:commons-io') {
version {
strictly '[2.11.0, 2.15['
prefer '2.11.0'
}
}
}
runtimeClasspath - Runtime classpath of source set 'main'.
+--- org.apache.commons:commons-csv:1.14.1
| +--- commons-io:commons-io:2.20.0 -> 2.11.0
| \--- commons-codec:commons-codec:1.19.0
\--- commons-io:commons-io:{strictly [2.11.0, 2.15[; prefer 2.11.0} -> 2.11.0
Gradle has a second kind of conflict that the other two tools do not model: a capability conflict. Two components that
claim the same capability, say two logging bindings that both provide com.example:logging, cannot both be on the
classpath, and Gradle fails the build unless a rule picks one. See
Gradle: Resolving capability conflicts
and Dependency Resolution, Conflicts & Capabilities.
Ivy: pick a conflict manager
Ivy resolves conflicts with a pluggable conflict manager. The default is latest-revision, which is what selected
commons-io 2.20.0 above. Setting <conflict manager="nearest"/> in the descriptor (Ivy 2.6.0 and later, "compatible with
the Maven conflict handling") makes Ivy choose what Maven chose:
<dependencies>
<dependency org="org.apache.commons" name="commons-csv" rev="1.14.1" conf="compile->default"/>
<dependency org="commons-io" name="commons-io" rev="2.11.0" conf="compile->default"/>
<conflict manager="nearest"/>
</dependencies>
[echo] Ivy runtime classpath:
[echo] commons-csv-1.14.1.jar
[echo] commons-codec-1.19.0.jar
[echo] commons-io-2.11.0.jar
With <conflict manager="strict"/> instead, Ivy refuses to choose. The build failed with the message below (the
scratch directory path is replaced by <dir>; the "impossible to resolve dependencies" line is Ant reporting the same
error):
[ivy:resolve] :: problems summary ::
[ivy:resolve] :::: ERRORS
[ivy:resolve] commons-io#commons-io;2.20.0 (needed by [org.apache.commons#commons-csv;1.14.1]) conflicts with commons-io#commons-io;2.11.0 (needed by [io.tidepool#tidepool-cli;1.0.0])
[ivy:resolve]
[ivy:resolve] :: USE VERBOSE OR DEBUG MESSAGE LEVEL FOR MORE DETAILS
BUILD FAILED
<dir>/build-strict.xml:12: impossible to resolve dependencies:
commons-io#commons-io;2.20.0 (needed by [org.apache.commons#commons-csv;1.14.1]) conflicts with commons-io#commons-io;2.11.0 (needed by [io.tidepool#tidepool-cli;1.0.0])
A strict manager is the Ivy way to make mediation a human decision: every conflict is a build failure until someone
writes down the winner. The conflict managers, force, and the override element are covered in depth on
Ivy Conflicts & Versions.
Source: Ivy: conflict managers and Ivy: conflict
The same decision in every tool
| I want… | Maven | Gradle | Ivy |
|---|---|---|---|
to let the tool decide |
nearest definition |
highest version |
|
to choose a version for a module wherever it occurs |
|
a constraint, or |
|
to forbid the build from guessing |
enforcer |
|
|
to cut a dependency out |
|
|
|
The Maven enforcer rule and the Gradle failOnVersionConflict() strategy (see
Gradle: Conflict resolution strategy)
are not run on this page; they appear on
Dependency Analysis & Convergence
and Dependency Resolution, Conflicts & Capabilities.
Version ranges, dynamic versions and snapshots
A version is usually fixed (1.14.1). All three tools can also express "a version in this set", which is convenient and
dangerous in the same breath: the same build file now resolves to different JARs on different days.
| Meaning | Maven | Gradle | Ivy |
|---|---|---|---|
1.0 up to but excluding 2.0 |
|
|
|
any 4.x |
a range, |
|
|
the newest available |
an open range, |
|
|
Source: Maven POM reference: Dependency Version Requirement Specification, Gradle: Declaring dynamic versions and Ivy: dependency, fixed and dynamic revisions
Each tool was asked for picocli in the 4.7 series with a range, to show what the range resolved to on 2026-10-16.
Maven:
<dependency>
<groupId>info.picocli</groupId>
<artifactId>picocli</artifactId>
<version>[4.7,5.0)</version>
</dependency>
[INFO] +- info.picocli:picocli:jar:4.7.7:compile
Gradle, in a one-dependency java-library project (Gradle 9.8.1, ./gradlew -q dependencies --configuration
runtimeClasspath; both DSLs printed the same line):
implementation("info.picocli:picocli:[4.7,5.0)")
implementation 'info.picocli:picocli:[4.7,5.0)'
\--- info.picocli:picocli:[4.7,5.0) -> 4.7.7
Ivy, with the range [4.7,5.0) in ivy-range.xml:
[ivy:resolve] found info.picocli#picocli;4.7.7 in central
[ivy:resolve] [4.7.7] info.picocli#picocli;[4.7,5.0)
All three found 4.7.7. They would find 4.7.8 tomorrow if it appears on Central, and the build you ran today would not be the build you run then.
Source: Maven POM reference: Dependency Version Requirement Specification, Gradle: Maven-style range and Ivy: dependency, fixed and dynamic revisions
Snapshots and changing modules
A snapshot (Maven) or a changing module (Gradle, Ivy) is a version that can be republished under the same number, the
usual way to share work in progress between projects. Maven writes -SNAPSHOT after the version and a repository stores
timestamped files, resolved according to an update policy (always, daily, interval:X, never; -U forces a check).
Gradle calls a Maven snapshot a changing version. By default Gradle caches dynamic versions and changing modules for 24 hours and then re-checks; the cache window is set with
cacheDynamicVersionsFor and cacheChangingModulesFor. Snapshot and changing-module resolution needs a repository to
republish into, so it is not run on this page; the details are on
Versions, Snapshots & Ranges and
Dependency Locking, Verification & Caching.
Determinism: pin what a range picked
A build is deterministic when the same sources produce the same dependency set, today and in a year. Ranges, latest.*
and snapshots all break that, and so does a transitive dependency whose version a library maintainer moved. The tools offer
three kinds of pin.
| Tool | Pin | What it records |
|---|---|---|
Maven |
Explicit versions, |
The versions are written in the POM; a transitive range is only as stable as the repository |
Gradle |
Dependency locking: |
A |
Ivy |
|
A resolved |
Gradle writes the lockfile when asked. The project below combines a range, a platform and locking; gradle dependencies
--write-locks produced gradle.lockfile, whose first lines for the dependencies above are:
plugins {
`java-library`
}
repositories {
mavenCentral()
}
dependencyLocking {
lockAllConfigurations()
}
dependencies {
implementation("org.apache.commons:commons-csv:1.14.1")
implementation("info.picocli:picocli:4.+")
implementation(platform("org.junit:junit-bom:6.1.3"))
testImplementation("org.junit.jupiter:junit-jupiter")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}
plugins {
id 'java-library'
}
repositories {
mavenCentral()
}
dependencyLocking {
lockAllConfigurations()
}
dependencies {
implementation 'org.apache.commons:commons-csv:1.14.1'
implementation 'info.picocli:picocli:4.+'
implementation platform('org.junit:junit-bom:6.1.3')
testImplementation 'org.junit.jupiter:junit-jupiter'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}
# This is a Gradle generated file for dependency locking.
# Manual edits can break the build and are not advised.
# This file is expected to be part of source control.
# To regenerate this file, run: ./gradlew :dependencies --write-locks
commons-codec:commons-codec:1.19.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
commons-io:commons-io:2.20.0=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
info.picocli:picocli:4.7.7=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
org.apache.commons:commons-csv:1.14.1=compileClasspath,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
Resolving again with the lockfile present, the same runtimeClasspath shows every module twice: once as it was
requested, and once as a strictly constraint taken from the lockfile, which is how Gradle makes the lock bite. The
dynamic 4.+ is still requested, but it can only select what the lock allows:
runtimeClasspath - Runtime classpath of source set 'main'.
+--- org.apache.commons:commons-csv:1.14.1
| +--- commons-io:commons-io:2.20.0
| \--- commons-codec:commons-codec:1.19.0
+--- info.picocli:picocli:4.+ -> 4.7.7
+--- org.junit:junit-bom:6.1.3
+--- org.junit:junit-bom:{strictly 6.1.3} -> 6.1.3 (c)
+--- org.apache.commons:commons-csv:{strictly 1.14.1} -> 1.14.1 (c)
+--- info.picocli:picocli:{strictly 4.7.7} -> 4.7.7 (c)
+--- commons-io:commons-io:{strictly 2.20.0} -> 2.20.0 (c)
\--- commons-codec:commons-codec:{strictly 1.19.0} -> 1.19.0 (c)
Ivy’s deliver task turns a dynamic descriptor into a static one. Given a descriptor with rev="[4.7,5.0)", after
ivy:resolve the delivered file was
<?xml version="1.0" encoding="UTF-8"?>
<ivy-module version="2.0">
<info organisation="io.tidepool" module="tidepool-cli" revision="1.0.0" status="release" publication="20261016111605"/>
<configurations>
<conf name="runtime"/>
</configurations>
<dependencies>
<dependency org="info.picocli" name="picocli" rev="4.7.7" revConstraint="[4.7,5.0)" conf="runtime->default"/>
</dependencies>
</ivy-module>
The task that produced it is
<ivy:resolve file="ivy-range.xml" conf="runtime" log="quiet"/>
<ivy:deliver deliverpattern="build/delivered/ivy-[revision].xml" pubrevision="1.0.0" status="release"/>
and the Ivy documentation notes that the replacement happens before conflict resolution, so the delivered file can differ from what is actually retrieved when the delivered descriptor is resolved again.
The cheapest rule of all is not to use ranges in anything you release, and to keep them, if at all, behind a lock.
BOMs and platforms
Libraries that ship as a family (JUnit, Spring, Jackson) must agree on their versions. Listing every member with its own
version is noisy and error prone, so the family publishes a bill of materials (BOM): a POM, with no code, whose
dependencyManagement lists the versions of all members. The three tools consume one differently.
| Tool | How a BOM is used | Effect |
|---|---|---|
Maven |
|
Copies the BOM’s managed versions into the importing POM; members can then be declared without a |
Gradle |
|
Treats the BOM as a set of constraints; members are declared without a version and get the platform’s version |
Ivy |
The Ivy POM parser expands the BOM into |
Overrides the version of every managed module without declaring a dependency on it |
A tidepool module that uses JUnit 6.1.3, as in the Gradle block above, imports the JUnit BOM in Maven like this. The
dependency:tree run below it shows junit-jupiter resolving with no version in the dependency entry, and every member
marked as managed:
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.junit</groupId>
<artifactId>junit-bom</artifactId>
<version>6.1.3</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
[INFO] \- org.junit.jupiter:junit-jupiter:jar:6.1.3:test
[INFO] +- org.junit.jupiter:junit-jupiter-api:jar:6.1.3:test (version managed from 6.1.3)
[INFO] | +- org.opentest4j:opentest4j:jar:1.3.0:test
[INFO] | +- org.junit.platform:junit-platform-commons:jar:6.1.3:test (version managed from 6.1.3)
[INFO] | | +- (org.apiguardian:apiguardian-api:jar:1.1.2:test - omitted for duplicate)
[INFO] | | \- (org.jspecify:jspecify:jar:1.0.0:test - omitted for duplicate)
[INFO] | +- org.apiguardian:apiguardian-api:jar:1.1.2:test
[INFO] | \- org.jspecify:jspecify:jar:1.0.0:test
[INFO] +- org.junit.jupiter:junit-jupiter-params:jar:6.1.3:test (version managed from 6.1.3)
(This is the first part of the tree; the rest continues the same way.) Gradle does the same with the
platform line; in the testRuntimeClasspath of the project that declares it, the members the platform covers appear in
the tree as constraints, marked (c):
+--- 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)
Ivy has no BOM concept of its own. It reads Maven’s, and the descriptor it wrote for commons-csv 1.14.1 (whose POM imports
BOMs) shows the translation: dependencyManagement versions became force="true" on the dependency
and the BOM entries became <override>:
<dependency org="commons-io" name="commons-io" rev="2.20.0" force="true" conf="compile->compile(*),master(*);runtime->runtime(*)"/>
<override org="org.junit.jupiter" module="junit-jupiter" matcher="exact" rev="5.13.1"/>
<override org="org.mockito" module="mockito-bom" matcher="exact" rev="4.11.0"/>
The same file has <override org="org.junit.platform" module="junit-platform-engine" matcher="exact" rev="1.13.1"/>
and about two dozen more lines like it; only these three are shown. An override is described by Ivy as a mediation rule
applied "before any else", in a phase called dependency descriptor mediation.
The difference in strength between the Maven import and the Gradle platform is worth remembering. A Maven import changes
the version of a dependency that is declared without one and of a transitive dependency that is mediated; it does not beat
a version written on a direct dependency, as the dependencyManagement example above showed. A Gradle platform is a
constraint, so it behaves like the constraints block above: it raises, it does not lower. For a platform that must win
over everything, Gradle offers enforcedPlatform, and strictly versions in the BOM itself. Authoring a BOM, ordering
several imports, and the Maven 4 (release candidate) bom packaging are on
Dependency Management & BOMs and
Platforms, Constraints & Rich Versions.
Module metadata: POM, ivy.xml and Gradle Module Metadata
The descriptor that sits next to a JAR is what turns a pile of files into a graph. Three formats are in use.
| Format | File | What it can say |
|---|---|---|
Maven POM |
|
coordinates, parent, packaging, dependencies with scope, optional and exclusions, |
Ivy module descriptor |
|
configurations and the mapping between them, published artifacts per configuration, dependency exclusions, conflict managers, overrides, branch |
Gradle Module Metadata |
|
variants (named sets of files and dependencies with attributes), capabilities, rich versions, dependency constraints, an explicit link to the files to download |
A POM describes one set of dependencies. A real library often needs two: what a consumer needs to compile against, and
what it needs to run with. Maven encodes that with scopes, Ivy with configurations, and Gradle with variants that carry
attributes describing their purpose. During resolution, Gradle matches the attributes its consumer asks for (a Java API
at JDK 26, say) against the variants the module offers. This is what the Variant runtime table printed by
dependencyInsight above was about. The org.gradle.usage attribute is how a library’s API variant (java-api) is told
from its runtime variant (java-runtime).
Libraries published by Gradle carry a .module file. This is the summary of the file for org.junit.jupiter:junit-jupiter-api
6.1.3, printed by a ten-line script from the file Gradle downloaded into its cache (the script reads the JSON and prints the
variant name, its usage attribute and the dependencies):
import json, sys
d = json.load(open(sys.argv[1]))
print("formatVersion", d["formatVersion"])
for v in d["variants"]:
deps = [f'{x["group"]}:{x["module"]}' for x in v.get("dependencies", [])]
print(v["name"], v["attributes"].get("org.gradle.usage"), deps)
The file was published with Gradle 9.5.1 (its createdBy field), had formatVersion 1.1 and these variants (the
dependency lists are cut to the first three; the sources and javadoc variants have none):
apiElements java-api ['org.junit:junit-bom', 'org.opentest4j:opentest4j', 'org.junit.platform:junit-platform-commons']
runtimeElements java-runtime ['org.junit:junit-bom', 'org.opentest4j:opentest4j', 'org.junit.platform:junit-platform-commons']
javadocElements java-runtime []
sourcesElements java-runtime []
The javadocElements and sourcesElements variants are the point of the format: a consumer asking for documentation, or an
IDE asking for sources, is told which files to fetch, where a Maven client has to guess a classifier. The Gradle project
we published earlier wrote the same kind of file next to its POM. Its summary, with Gradle 9.8.1, was
formatVersion 1.1
apiElements java-api ['org.apache.commons:commons-csv']
runtimeElements java-runtime ['info.picocli:picocli', 'org.apache.commons:commons-csv', 'commons-io:commons-io']
Here the api/implementation split that the POM could not carry is explicit: commons-csv is part of the API variant, the
other two only of the runtime variant. Compatibility is the other half of the design: the POM written next to the .module file begins with a comment
saying that the module was also published with a richer model and asking tools not to delete the line that follows it, and a
Maven or Ant consumer simply ignores the .module file and reads the POM. Gradle warns at publication time when a
build uses a feature that only the .module file can express.
The comparison on one graph
Everything above on the same two lines of the tidepool-cli graph:
| Tool and setting | commons-io selected |
Why |
|---|---|---|
Maven 3.10.0 and 3.9.16, direct |
2.11.0 |
nearest definition |
Maven 4.0.0-rc-7 (release candidate), direct |
2.11.0 |
nearest definition |
Maven, |
2.20.0 |
managed version applies to the declaration |
Gradle 9.8.1, plain |
2.20.0 |
highest version |
Gradle, |
2.11.0 |
the strict range |
Ant with the Resolver Ant Tasks 2.0.0 |
2.11.0 |
Maven Resolver, nearest |
Ant with Ivy 2.6.0, default manager |
2.20.0 |
|
Ant with Ivy 2.6.0, |
2.11.0 |
nearest |
Ant with Ivy 2.6.0, |
build fails |
conflict is an error |
The differences are real and none of them is a bug in any tool. A project that moves between tools, or mixes them, should decide the version it wants and write it where the target tool honours it; the moves are in Migrating Between Tools, and the choice among the three tools is in Comparing Ant, Maven & Gradle.
Where to go next
| Tool | Pages |
|---|---|
Ant |
Ivy Overview, Ivy Module Descriptors & Configurations, Ivy Conflicts & Versions and Maven Resolver Ant Tasks |
Maven |
Dependencies, Scopes & Mediation, Dependency Management & BOMs, Versions, Snapshots & Ranges and Dependency Analysis & Convergence |
Gradle |
Declaring Dependencies & Configurations, Version Catalogs, Platforms, Constraints & Rich Versions, Dependency Resolution, Conflicts & Capabilities, Variant-Aware Resolution & Module Metadata and Dependency Locking, Verification & Caching |
Shared |
Repositories & Publishing Across Tools and Supply Chain Security, for the question of whether the JARs you resolved are the ones you meant to resolve |