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

groupId:artifactId:version, written as child elements of a <dependency>; command-line plugin parameters use groupId:artifactId:version[:packaging[:classifier]]

groupId, artifactId, version, type (default jar, which decides the file extension) and classifier

Gradle

group:name:version[:classifier][@extension], for example commons-io:commons-io:2.20.0:sources@jar

group, name, version, classifier, extension; the component also has capabilities, by default group:name:version itself

Ivy (and so Ant with Ivy)

org, name and rev attributes on a <dependency>; printed as organisation#module;revision, for example org.apache.commons#commons-csv;1.14.1

organisation, module, optionally branch, revision; artifacts are described separately

Maven Resolver Ant Tasks

coords="groupId:artifactId:version" on <dependency>, or the groupId, artifactId, version attributes

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.jar and commons-io-2.20.0-sources.jar. The type in Maven or the extension in Gradle is the file extension. Maven maps a type to an extension (and sometimes a classifier) through an ArtifactHandler; 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
Kotlin DSL
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) }
    }
}
Groovy DSL
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.

flowchart TD CLI["tidepool-cli"] --> CSV["commons-csv 1.14.1"] CLI -->|"declares 2.11.0"| IO11["commons-io 2.11.0"] CSV -->|"asks for 2.20.0"| IO20["commons-io 2.20.0"] CSV --> COD["commons-codec 1.19.0"] IO11 -.->|"selected by"| MV["Maven, Resolver Ant Tasks, Ivy nearest"] IO20 -.->|"selected by"| GR["Gradle, Ivy latest-revision"]

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".

Kotlin DSL
plugins {
    `java-library`
}

repositories {
    mavenCentral()
}

dependencies {
    implementation("org.apache.commons:commons-csv:1.14.1")
    implementation("commons-io:commons-io:2.11.0")
}
Groovy DSL
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.

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->default reads "my compile needs the dependency’s default".

  • 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, testImplementation and others for you.

Maven scope Gradle (java-library) Ivy configuration (a convention) Meaning

compile (the default)

api for what the module exposes; implementation for what it does not

compile

On every classpath; Maven passes it on to consumers. Gradle’s api is on a consumer’s compile classpath, implementation only on its runtime classpath.

provided

compileOnly

provided

On the compile classpath only; supplied by the JDK or a container at run time; not transitive.

runtime

runtimeOnly

runtime

Needed to run, not to compile.

test

testImplementation, testRuntimeOnly, testCompileOnly

test

Only for compiling and running tests; not transitive.

system

a file dependency, files("libs/x.jar")

none; a file-system resolver can serve a local directory

A JAR at a fixed path that is not looked up in a repository.

import (on a pom-type entry of dependencyManagement)

platform("g:n:v")

<override> and force, see BOMs and platforms

Copies version information from a BOM; adds no dependency by itself.

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

Kotlin DSL
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"))
        }
    }
}
Groovy DSL
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

commons-io 2.11.0 (depth 1) beats the 2.20.0 requested by commons-csv (depth 2)

Gradle

The highest of all requested versions wins, wherever it appears in the graph, unless a constraint or a strictly says otherwise

commons-io 2.20.0

Ivy

A conflict manager, per module or for all: latest-revision (the default), latest-time, latest-compatible, strict, and nearest (since 2.6.0)

2.20.0 by default; 2.11.0 with nearest; a build failure with strict

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-cli graph shows: commons-csv 1.14.1 was built against commons-io 2.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 -Dverbose tree 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-revision is "latest" by a string-oriented comparison of revisions (the documentation points out that latest-time is more costly to compute). Ivy also honours the force attribute of a direct dependency under the two latest managers.

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:

Kotlin DSL
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")
        }
    }
}
Groovy DSL
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:

Kotlin DSL
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")
        }
    }
}
Groovy DSL
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.

The same decision in every tool

I want…​ Maven Gradle Ivy

to let the tool decide

nearest definition

highest version

latest-revision

to choose a version for a module wherever it occurs

dependencyManagement

a constraint, or platform()

<override>

to forbid the build from guessing

enforcer dependencyConvergence

strictly, or failOnVersionConflict()

<conflict manager="strict"/>

to cut a dependency out

<exclusions>

exclude

<exclude>

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

[1.0,2.0)

[1.0,2.0)

[1.0,2.0[; the form [1.0,2.0) was also accepted in the run below

any 4.x

a range, [4.0,5.0)

4.+

4.+

the newest available

an open range, [1.0,)

latest.release, latest.integration

latest.integration, or latest. plus a status such as latest.release

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):

Kotlin DSL
implementation("info.picocli:picocli:[4.7,5.0)")
Groovy DSL
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.

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, dependencyManagement, and a BOM import; no lockfile in the core

The versions are written in the POM; a transitive range is only as stable as the repository

Gradle

Dependency locking: lockAllConfigurations() and --write-locks

A gradle.lockfile with the complete resolved set of each configuration

Ivy

ivy:deliver, which replaces dynamic revisions with the resolved ones in the published descriptor

A resolved ivy.xml, with the requested range kept in revConstraint

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:

Kotlin DSL
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")
}
Groovy DSL
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

<scope>import</scope> with <type>pom</type> in dependencyManagement

Copies the BOM’s managed versions into the importing POM; members can then be declared without a <version>

Gradle

platform("g:n:v") (or enforcedPlatform)

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 <override> elements

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

artifact-version.pom

coordinates, parent, packaging, dependencies with scope, optional and exclusions, dependencyManagement, properties, licences, developers, SCM; no way to say which artifacts differ in what they are for

Ivy module descriptor

ivy-revision.xml, or ivy.xml in a build

configurations and the mapping between them, published artifacts per configuration, dependency exclusions, conflict managers, overrides, branch

Gradle Module Metadata

artifact-version.module, JSON

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

2.11.0

nearest definition

Maven 4.0.0-rc-7 (release candidate), direct 2.11.0

2.11.0

nearest definition

Maven, dependencyManagement 2.20.0 and the direct version removed

2.20.0

managed version applies to the declaration

Gradle 9.8.1, plain

2.20.0

highest version

Gradle, strictly [2.11.0, 2.15[

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

latest-revision

Ant with Ivy 2.6.0, nearest

2.11.0

nearest

Ant with Ivy 2.6.0, strict

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.