Maven Dependencies, Scopes & Mediation
|
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 Maven 3.10.0 with the Apache Maven Dependency Plugin 3.11.0 and the Apache Maven Compiler
Plugin 3.16.0. Every example was run on JDK 26 on 2026-10-16. Wherever the text says that Maven 3.9.16 or Maven 4.0.0-rc-7 (a
release candidate) behaves the same, both were run as well; where they differ, the difference is named. In the console output
the [INFO] prefix was removed from the tree lines, the one line Loaded N auto-discovered prefixes for remote repository ...
that Maven 3.10 prints while resolving was left out, and paths were shortened.
Declaring a dependency takes five lines. Knowing what you get takes the rest of this page: which classpaths a dependency joins
(its scope), which of its own dependencies you inherit (transitivity), which version wins when two paths disagree
(mediation), and how to say no (optional and exclusions). How the graph is inspected is on
Dependency Analysis & Convergence;
dependencyManagement, BOMs and the import scope in depth are on
Dependency Management & BOMs; version
syntax, SNAPSHOTs and ranges are on
Versions, Snapshots & Ranges; getting a jar
that no repository holds is on
Installing Third-Party JARs & Relocation.
The POM itself is introduced on POM Fundamentals; the
tool-independent vocabulary is on
Dependency Management Fundamentals.
Declaring a dependency
A project lists what it needs in <dependencies>. Here is the tidepool engine module, which needs its own model module and
Commons CSV:
<dependencies>
<dependency>
<groupId>io.tidepool</groupId>
<artifactId>tidepool-model</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-csv</artifactId>
<version>1.14.1</version>
</dependency>
</dependencies>
The elements of a <dependency> are:
| Element | Meaning |
|---|---|
|
The coordinates of the artifact (see
Philosophy, Coordinates & Directory Layout).
|
|
The kind of artifact, |
|
An optional string appended to the file name after the version, to tell apart artifacts built from the same POM, such as
|
|
|
|
Only for the |
|
|
|
A list of |
The identity of a dependency is groupId:artifactId:type:classifier; the version is not part of it. A project may therefore
declare each identity once. That rule used to be only a warning and is now enforced; see Maven 3.10 refuses duplicate declarations.
Source: POM reference: Dependencies
The six scopes
A scope does two things: it limits the classpaths a dependency appears on in this build, and it limits how the dependency travels to projects that depend on this one.
| Scope | On which classpaths (this project) | Intended for | Transitive? |
|---|---|---|---|
|
Compile, runtime and test. |
Libraries the main code needs to compile and run, such as Commons CSV for |
Yes |
|
Compile and test, but not runtime. |
APIs the JDK or a container supplies at run time, such as the Servlet API. |
No |
|
Runtime and test, but not compile. |
Implementations the code never references directly, such as a JDBC driver or a logging backend. |
Yes |
|
Test compilation and test execution only. |
Test frameworks and fixtures, such as JUnit Jupiter and Mockito. |
No |
|
Compile and test, but not runtime (like |
Discouraged; see The system scope and the historical javax libraries. |
No |
|
None: only valid on a |
Importing a BOM. See Dependency Management & BOMs. |
Not applicable |
The five classpath-bearing scopes are easy to verify on a throwaway project. Its four dependencies are empty jars in a local
file:// repository whose group is demo, one per scope (dep-compile, dep-provided, dep-runtime, dep-test), so that
nothing but the scope can explain the result. The dependency plugin’s build-classpath goal takes an includeScope and prints
the artifacts for it; the file names below are what remained after cutting each path to its last segment
(mvn dependency:build-classpath -DincludeScope=compile, then runtime, then test):
includeScope=compile -> dep-compile-1.0.jar dep-provided-1.0.jar
includeScope=runtime -> dep-compile-1.0.jar dep-runtime-1.0.jar
includeScope=test -> dep-compile-1.0.jar dep-provided-1.0.jar dep-runtime-1.0.jar dep-test-1.0.jar
That is the table above, measured: provided is missing from the runtime classpath, runtime is missing from the compile
classpath, and test is only on the test classpath. The system scope was run separately, with one jar declared through systemPath: includeScope=compile and
includeScope=test listed it, and includeScope=runtime listed nothing, which is the same pattern as provided.
Transitive dependencies and scope propagation
Maven reads the POM of every dependency and adds that POM’s own dependencies, and theirs, to the graph. There is no depth limit; only a cycle is an error. The scope that a transitive dependency ends up with is the combination of how you declared the dependency above it and how that dependency declared it. For a dependency A declared in your project (rows) and a dependency B that A declares (columns), the resulting scope of B is:
| A declared as, B declared as | compile |
provided |
runtime |
test |
|---|---|---|---|---|
|
|
omitted |
|
omitted |
|
|
omitted |
|
omitted |
|
|
omitted |
|
omitted |
|
|
omitted |
|
omitted |
Two rules fall out of it: provided and test dependencies of a dependency are never inherited, and the inherited scope is
never wider than the scope you gave the dependency above it. The documentation gives the table as a matrix; here it was
reproduced by running it. A library lib declares one dependency per scope (dep-compile as compile, dep-provided as
provided, dep-runtime as runtime, dep-test as test), and four projects declare lib with each of the four scopes in
turn:
<project xmlns="http://maven.apache.org/POM/4.0.0">
<modelVersion>4.0.0</modelVersion>
<groupId>demo</groupId>
<artifactId>lib</artifactId>
<version>1.0</version>
<dependencies>
<dependency>
<groupId>demo</groupId>
<artifactId>dep-compile</artifactId>
<version>1.0</version>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>demo</groupId>
<artifactId>dep-provided</artifactId>
<version>1.0</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>demo</groupId>
<artifactId>dep-runtime</artifactId>
<version>1.0</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>demo</groupId>
<artifactId>dep-test</artifactId>
<version>1.0</version>
<scope>test</scope>
</dependency>
</dependencies>
</project>
mvn dependency:tree on each of the four projects (the first line of every tree is the project itself):
lib declared compile:
\- demo:lib:jar:1.0:compile
+- demo:dep-compile:jar:1.0:compile
\- demo:dep-runtime:jar:1.0:runtime
lib declared provided:
\- demo:lib:jar:1.0:provided
+- demo:dep-compile:jar:1.0:provided
\- demo:dep-runtime:jar:1.0:provided
lib declared runtime:
\- demo:lib:jar:1.0:runtime
+- demo:dep-compile:jar:1.0:runtime
\- demo:dep-runtime:jar:1.0:runtime
lib declared test:
\- demo:lib:jar:1.0:test
+- demo:dep-compile:jar:1.0:test
\- demo:dep-runtime:jar:1.0:test
dep-provided and dep-test never appear, and the other two keep or lose width exactly as the table says. Maven 3.9.16 and
Maven 4.0.0-rc-7 print the same four trees. The system scope is not part of the table in the documentation and was not
tabulated here.
The tidepool CLI graph
A real graph shows all of this at once. Besides the engine and picocli, the tidepool CLI module is given one more
dependency, commons-fileupload 1.5 (an upload feature), and the engine dependency carries an exclusion for Commons Codec. Two
libraries now need Commons IO: Commons CSV, by way of the engine, wants 2.20.0, and Commons FileUpload wants 2.11.0. This
POM is this page’s own lab setup, not an example from the Maven documentation.
<dependencies>
<dependency>
<groupId>io.tidepool</groupId>
<artifactId>tidepool-engine</artifactId>
<version>${project.version}</version>
<exclusions>
<exclusion>
<groupId>commons-codec</groupId>
<artifactId>commons-codec</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>info.picocli</groupId>
<artifactId>picocli</artifactId>
<version>4.7.7</version>
</dependency>
<dependency>
<groupId>commons-fileupload</groupId>
<artifactId>commons-fileupload</artifactId>
<version>1.5</version>
</dependency>
</dependencies>
Solid arrows are the dependencies Maven keeps; dashed arrows are the two edges it removes. The first is the loser of a
mediation (next section), the second is the exclusion. The same graph from mvn -pl tidepool-cli -am dependency:tree -Dverbose
-Dscope=compile, before and after adding the exclusion:
Before the exclusion:
io.tidepool:tidepool-cli:jar:1.0.0
+- io.tidepool:tidepool-engine:jar:1.0.0:compile
| +- io.tidepool:tidepool-model:jar:1.0.0:compile
| \- org.apache.commons:commons-csv:jar:1.14.1:compile
| +- (commons-io:commons-io:jar:2.20.0:compile - omitted for conflict with 2.11.0)
| \- commons-codec:commons-codec:jar:1.19.0:compile
+- info.picocli:picocli:jar:4.7.7:compile
\- commons-fileupload:commons-fileupload:jar:1.5:compile
\- commons-io:commons-io:jar:2.11.0:compile
After the exclusion:
io.tidepool:tidepool-cli:jar:1.0.0
+- io.tidepool:tidepool-engine:jar:1.0.0:compile
| +- io.tidepool:tidepool-model:jar:1.0.0:compile
| \- org.apache.commons:commons-csv:jar:1.14.1:compile
| \- (commons-io:commons-io:jar:2.20.0:compile - omitted for conflict with 2.11.0)
+- info.picocli:picocli:jar:4.7.7:compile
\- commons-fileupload:commons-fileupload:jar:1.5:compile
\- commons-io:commons-io:jar:2.11.0:compile
The -am option makes the engine and model modules part of the build, so the reactor resolves them; the tree is identical
on Maven 3.9.16, 3.10.0 and 4.0.0-rc-7. Commons CSV was built against Commons IO 2.20.0 and the CLI ships 2.11.0 instead,
which is the usual price of mediation, discussed next.
Dependency mediation
When the same artifact (same groupId:artifactId:type:classifier) is reached by several paths with different versions, Maven
keeps exactly one version. The rule is nearest definition: the version on the path with the fewest edges from your project
wins. If two paths are equally long, the one whose first edge was declared first in the POM wins.
Nearest wins
The documentation’s own example is reproduced with five tiny artifacts. b depends on c, which depends on d 2.0; e
depends on d 1.0. The project declares b and then e. d 2.0 is three edges away, d 1.0 is two (mvn dependency:tree -Dverbose):
io.tidepool:near:jar:1.0.0
+- demo:b:jar:1.0:compile
| \- demo:c:jar:1.0:compile
| \- (demo:d:jar:2.0:compile - omitted for conflict with 1.0)
\- demo:e:jar:1.0:compile
\- demo:d:jar:1.0:compile
c asked for d 2.0 and receives 1.0. Maven does not check that the winner is new enough for everyone; it only counts edges.
The cure is to be explicit. Declaring d 2.0 in the project itself puts it one edge away, where it beats both:
io.tidepool:near-forced:jar:1.0.0
+- demo:b:jar:1.0:compile
| \- demo:c:jar:1.0:compile
| \- (demo:d:jar:2.0:compile - omitted for duplicate)
+- demo:e:jar:1.0:compile
| \- (demo:d:jar:1.0:compile - omitted for conflict with 2.0)
\- demo:d:jar:2.0:compile
A direct declaration is the simplest fix; dependencyManagement or a BOM (see
Dependency Management & BOMs) is the fix
that scales, because it also governs versions reached transitively, and the enforcer rules on
Dependency Analysis & Convergence
turn the disagreement into a build failure instead of a silent choice. Declaring directly every library whose classes your code
imports is also good hygiene, so that the build does not depend on another library’s choices.
A tie: declaration order decides
m and n are both one edge from the project, and each depends on a different version of z. With m declared first, z 1.0 wins:
io.tidepool:tie-mn:jar:1.0.0
+- demo:m:jar:1.0:compile
| \- demo:z:jar:1.0:compile
\- demo:n:jar:1.0:compile
\- (demo:z:jar:2.0:compile - omitted for conflict with 1.0)
Swapping the two <dependency> blocks, and nothing else, flips the result:
io.tidepool:tie-nm:jar:1.0.0
+- demo:n:jar:1.0:compile
| \- demo:z:jar:2.0:compile
\- demo:m:jar:1.0:compile
\- (demo:z:jar:1.0:compile - omitted for conflict with 2.0)
Maven 3.9.16 and Maven 4.0.0-rc-7 gave the same two trees as Maven 3.10.0. A reformatting that merely reorders dependencies can
therefore change the version of a library without any visible diff to that library, which is one more reason to manage versions
centrally. Mediation has a second input that this page does not use: a dependencyManagement entry for the artifact replaces
the version whatever the paths, and the tree marks such nodes with version managed from, as in the JUnit nodes of the tree above.
Classpath order in Maven 3.10
The order of the jars on a classpath matters when two jars hold the same class. Maven 3.10.0 changed how a dependency graph is
flattened into a list: from pre-order (depth first: a dependency, then everything below it, then the next sibling) to
level-order (breadth first: all direct dependencies, then their dependencies, and so on). This aligns it with Maven 4 and the
Maven Resolver 2.x default. The release notes list it as a potentially breaking change and give a switch back:
-Daether.system.dependencyVisitor=preOrder.
To see it, two direct dependencies are declared in this order: p, which depends on p1, which depends on p2, and then q.
Pre-order gives p p1 p2 q; level-order gives p q p1 p2. The classpath handed to javac by mvn -X compile was identical on
Maven 3.9.16, Maven 3.10.0 (with and without the switch) and Maven 4.0.0-rc-7:
p-1.0.jar p1-1.0.jar p2-1.0.jar q-1.0.jar
In this experiment the order of the project’s own compile classpath therefore did not change. Where the change is visible is
in the class realm of a plugin that is given extra <dependencies>, because a plugin’s realm is built from the resolver’s list.
The same two dependencies were added to the dependency plugin’s declaration, and the order of the Included: lines in the
mvn -X dependency:tree output (pipeline: keep the Included: demo: lines that follow Populating class realm
plugin>org.apache.maven.plugins:maven-dependency-plugin) was:
Maven 3.9.16 demo:p demo:p1 demo:p2 demo:q
Maven 3.10.0 demo:p demo:q demo:p1 demo:p2
Maven 3.10.0 -Daether.system.dependencyVisitor=preOrder demo:p demo:p1 demo:p2 demo:q
Maven 4.0.0-rc-7 demo:p demo:q demo:p1 demo:p2
So 3.10 and 4 agree with each other, and the switch restores the 3.9 order. What other consumers of the resolver’s list exist, and whether a given plugin builds its classpath from it, was not investigated here; if a build relies on one jar shadowing another, make the order explicit (and better, remove the duplicate class) instead of depending on either traversal.
Optional dependencies
Marking a dependency <optional>true</optional> keeps it in the declaring project’s own graph and hides it from every project
that depends on the declaring project. Think of it as "excluded by default". The typical author is a library that supports many
backends and cannot be split: it needs every driver to compile, but its users need only one. Here store declares
driver-a as optional and driver-b as a normal dependency:
<dependency>
<groupId>demo</groupId>
<artifactId>driver-a</artifactId>
<version>1.0</version>
<optional>true</optional>
</dependency>
A project that depends on store gets only driver-b:
io.tidepool:opt-plain:jar:1.0.0
\- demo:store:jar:1.0:compile
\- demo:driver-b:jar:1.0:compile
The project chooses driver-a by declaring it itself:
io.tidepool:opt-direct:jar:1.0.0
+- demo:store:jar:1.0:compile
| \- demo:driver-b:jar:1.0:compile
\- demo:driver-a:jar:1.0:compile
Optional dependencies keep jars that conflict, or whose licence you cannot ship, out of WARs, EARs and fat jars. They are a stop-gap: the clean design is to split the library into modules whose dependencies are all normal.
Source: Optional dependencies
Exclusions
An <exclusion> removes an artifact, and everything that is reachable only through it, from the graph below one specific
dependency. It is the consumer’s tool, used when the library you depend on did not mark the dependency optional. In the example, top depends on mid, which depends on leaf1 and leaf2; the project declares top.
Without an exclusion the whole chain arrives:
io.tidepool:excl-none:jar:1.0.0
\- demo:top:jar:1.0:compile
\- demo:mid:jar:1.0:compile
+- demo:leaf1:jar:1.0:compile
\- demo:leaf2:jar:1.0:compile
Excluding demo:leaf1 on the top dependency removes only that leaf:
<dependency>
<groupId>demo</groupId>
<artifactId>top</artifactId>
<version>1.0</version>
<exclusions>
<exclusion>
<groupId>demo</groupId>
<artifactId>leaf1</artifactId>
</exclusion>
</exclusions>
</dependency>
io.tidepool:excl-one:jar:1.0.0
\- demo:top:jar:1.0:compile
\- demo:mid:jar:1.0:compile
\- demo:leaf2:jar:1.0:compile
Excluding demo:mid instead removes mid and, because they are reachable only through it, both leaves: the tree of that
project is just demo:top:jar:1.0:compile under the project. A wildcard exclusion, with * as both groupId and artifactId, removes
every transitive dependency of that dependency and leaves you in charge of the rest:
<exclusions>
<exclusion>
<groupId>*</groupId>
<artifactId>*</artifactId>
</exclusion>
</exclusions>
io.tidepool:excl-wild:jar:1.0.0
\- demo:top:jar:1.0:compile
An exclusion applies to the path it is written on, not to the whole project. If the same artifact is reachable by another path,
it comes back. With top (excluding leaf1) and a second, direct dependency on mid:
io.tidepool:excl-path:jar:1.0.0
+- demo:top:jar:1.0:compile
\- demo:mid:jar:1.0:compile
+- demo:leaf1:jar:1.0:compile
\- demo:leaf2:jar:1.0:compile
Because of that, exclusions are also published: if you deploy a project with an exclusion, the projects that depend on it
inherit the exclusion for that edge. If the intent is that an artifact must never appear anywhere, the enforcer’s
bannedDependencies rule fails the build and names the paths that still need an exclusion (the enforcer is covered on
The Enforcer Plugin). Use exclusions as a last resort, after
asking the library to mark the dependency optional, and write down why.
The system scope and the historical javax libraries
A system dependency is not looked up in any repository. The dependency names a jar on the local disk with systemPath:
<dependency>
<groupId>com.example.vendor</groupId>
<artifactId>vendor-sdk</artifactId>
<version>1.0.0</version>
<scope>system</scope>
<systemPath>${project.basedir}/lib/vendor.jar</systemPath>
</dependency>
It works, and the tree shows it with the scope system. The build is also now loud about it. Maven 3.10.0 and Maven 4.0.0-rc-7
warn that the scope is deprecated and that a path inside the project directory cannot be resolved by dependent projects (in
Maven 3.9.16 the first warning is absent; the second is printed):
[WARNING] Some problems were encountered while building the effective model for io.tidepool:sysscope:jar:1.0.0
[WARNING] 'dependencies.dependency.scope' for com.example.vendor:vendor-sdk:jar declares usage of deprecated 'system' scope @ line 17, column 14
[WARNING] 'dependencies.dependency.systemPath' for com.example.vendor:vendor-sdk:jar should not point at files within the project directory, ${project.basedir}/lib/vendor.jar will be unresolvable by dependent projects @ line 18, column 19
...
[INFO] --- dependency:3.11.0:tree (default-cli) @ sysscope ---
[INFO] io.tidepool:sysscope:jar:1.0.0
[INFO] \- com.example.vendor:vendor-sdk:jar:1.0.0:system
(The ellipsis line replaces the rest of the Maven warning block.) The reasons are practical: the build is bound to one path on one
machine, and a system dependency cannot be told apart as compile-only or test-only. Install the jar into a repository instead;
see Installing Third-Party JARs & Relocation.
In the past system was also used to point at JDK-supplied Java EE packages, for example javax.security:jaas with
${java.home}/lib/rt.jar. That no longer works and should never be done: classes that belong to the JDK need no dependency at all, and
those that were removed from the JDK come from Maven Central. The clearest case is the XML binding API javax.xml.bind, which
JEP 320 removed from the JDK in Java 11. This one-class project compiles javax.xml.bind.DatatypeConverter with
maven.compiler.release 21 on JDK 26 and fails:
[INFO] --- compiler:3.16.0:compile (default-compile) @ javax-demo ---
[INFO] Recompiling the module because of changed source code.
[INFO] Compiling 1 source file with javac [debug release 21] to target/classes
[ERROR] COMPILATION ERROR :
[ERROR] .../javax/src/main/java/io/tidepool/Hex.java:[3,22] package javax.xml.bind does not exist
[ERROR] .../javax/src/main/java/io/tidepool/Hex.java:[10,16] cannot find symbol
symbol: variable DatatypeConverter
location: class io.tidepool.Hex
[INFO] 2 errors
[ERROR] BUILD FAILURE
Declaring javax.xml.bind:jaxb-api:2.3.1 fixes it, and brings in the activation API as a transitive dependency:
io.tidepool:javax-demo:jar:1.0.0
\- javax.xml.bind:jaxb-api:jar:2.3.1:compile
\- javax.activation:javax.activation-api:jar:1.2.0:compile
The Java EE specifications have since moved to the Eclipse Foundation and to the jakarta.* package names, so new code should
use those. Changing the import to jakarta.xml.bind.DatatypeConverter and the dependency to
jakarta.xml.bind:jakarta.xml.bind-api:4.0.5 compiled on the same setup, with jakarta.activation:jakarta.activation-api:2.1.4 as
the transitive dependency. Moving a large code base from javax.* to jakarta.* is a source change, not a build setting; that
part is not covered here.
Maven 3.10 refuses duplicate declarations
Declaring the same identity twice, with the same or different versions, used to be a warning and the last declaration won.
Maven 3.10.0 turns it into an error. A project that declares demo:d 1.0 and then demo:d 2.0:
Maven 3.9.16:
[WARNING] 'dependencies.dependency.(groupId:artifactId:type:classifier)' must be unique: demo:d:jar -> version 1.0 vs 2.0 @ line 18, column 17
...
[INFO] \- demo:d:jar:2.0:compile
[INFO] BUILD SUCCESS
Maven 3.10.0:
[ERROR] [ERROR] Some problems were encountered while processing the POMs:
[ERROR] 'dependencies.dependency.(groupId:artifactId:type:classifier)' must be unique: demo:d:jar -> version 1.0 vs 2.0 @ line 18, column 17
[ERROR] The build could not read 1 project -> [Help 1]
Maven 4.0.0-rc-7 also rejects the project with the same message. This is part of the stricter POM validation listed in the 3.10.0
release notes; the fix is to keep one declaration (or to put the version in dependencyManagement). The release notes also
describe a maven.resolver.validation property, but that property concerns the validation of artifact coordinates, not this
check. Passing the lowercase -Dmaven.resolver.validation=off shown in the release notes made Maven 3.10.0 stop with an internal
error about an unknown enum constant off when tried here, so it is not recommended as a way around this check.
Where older material is outdated
-
Older books and articles describe
systemas a normal way to use a vendor jar or a JDK jar; the current guidance is to avoid it, and Maven 3.10 and 4 say so with a warning. -
Texts that show
javax.*APIs as part of the JDK, orrt.jarpaths, predate Java 9 and Java 11; see the Java EE section above. -
Dependency order used to be "pre-order" without anyone noticing; it is level-order from Maven 3.10 on, at least for plugin class realms.
-
Duplicate dependency declarations were tolerated for years; they now fail the build in 3.10.
Summary of pitfalls
-
Mediation counts edges, not versions: the nearest declaration can be the older one. Use
dependency:tree -Dverboseto see the loser, and a direct declaration ordependencyManagementto decide. -
On a tie, declaration order decides; reordering dependencies can change the classpath silently.
-
providedandtestdependencies of your dependencies are never inherited; if a library’s ownprovidedAPI is needed at run time, you must declare it. -
An exclusion removes one path only; the same artifact can still arrive by another.
-
A wildcard exclusion removes everything below the dependency, including what the library really needs; add back what you use.
-
optionalhides a dependency from your consumers, not from yourself; it is also no replacement for splitting a module. -
The
systemscope ties the build to one machine; install the jar in a repository instead. -
Duplicate dependency declarations fail the build from Maven 3.10.