Maven exclusions and optional dependencies
last modified November 9, 2026
In this article we show how to control the transitive dependencies of a Maven project. We remove dependencies with exclusions, publish a library with optional dependencies, and track down a version conflict that breaks an application at runtime. The examples use Maven 3.10 and Java 25.
When we declare a dependency, Maven also adds the dependencies of that
dependency, and so on. The basic rules of this process are described in the
Maven dependency resolution
article. Here we look at the tools that let us change the result: the
exclusions element on the consumer side, the
optional element on the library side, and
dependencyManagement for version conflicts.
The example project
Our first project uses Guava to count the words of a sentence.
<?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>com.example</groupId>
<artifactId>words</artifactId>
<version>1.0.0</version>
<properties>
<maven.compiler.release>25</maven.compiler.release>
</properties>
<dependencies>
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>33.8.0-jre</version>
</dependency>
</dependencies>
</project>
The project has a single dependency, Guava 33.8.0.
package com.example;
import com.google.common.collect.ImmutableMultiset;
import com.google.common.collect.Multisets;
public class App {
public static void main(String[] args) {
var text = "an old falcon in the sky and an old eagle in the sky";
var words = ImmutableMultiset.copyOf(text.split(" "));
for (var e : Multisets.copyHighestCountFirst(words).entrySet()) {
if (e.getCount() > 1) {
System.out.printf("%-6s %d%n", e.getElement(), e.getCount());
}
}
}
}
The program builds an immutable multiset of words and prints the words that occur more than once, the most frequent first.
Guava brings five dependencies of its own. The failureaccess
library contains a class that Guava's futures extend. The
listenablefuture artifact is an empty placeholder, which
prevents an old copy of Guava's ListenableFuture from being
added by other libraries. The remaining three JARs contain only annotations.
$ mvn dependency:tree [INFO] com.example:words:jar:1.0.0 [INFO] \- com.google.guava:guava:jar:33.8.0-jre:compile [INFO] +- com.google.guava:failureaccess:jar:1.0.3:compile [INFO] +- com.google.guava:listenablefuture:jar:9999.0-empty-to-avoid-conflict-with-guava:compile [INFO] +- org.jspecify:jspecify:jar:1.0.1:compile [INFO] +- com.google.errorprone:error_prone_annotations:jar:2.52.0:compile [INFO] \- com.google.j2objc:j2objc-annotations:jar:3.1:compile ... $ mvn -q compile exec:java -Dexec.mainClass=com.example.App an 2 old 2 in 2 the 2 sky 2
Excluding transitive dependencies
The annotations are used by static analysis tools when Guava itself is built. Java does not need annotation classes at runtime: annotations whose classes are missing are silently ignored. Many projects therefore remove these JARs from their applications.
<dependencies>
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>33.8.0-jre</version>
<exclusions>
<exclusion>
<groupId>org.jspecify</groupId>
<artifactId>jspecify</artifactId>
</exclusion>
<exclusion>
<groupId>com.google.errorprone</groupId>
<artifactId>error_prone_annotations</artifactId>
</exclusion>
<exclusion>
<groupId>com.google.j2objc</groupId>
<artifactId>j2objc-annotations</artifactId>
</exclusion>
<exclusion>
<groupId>com.google.guava</groupId>
<artifactId>listenablefuture</artifactId>
</exclusion>
</exclusions>
</dependency>
</dependencies>
Each exclusion names an artifact by its group ID and artifact
ID; there is no version and no type. The exclusion applies to the whole
subtree below the dependency on which it is declared. If an excluded artifact
had dependencies of its own, they would be removed as well.
We have removed four JARs and the program works as before. The compiler may print a warning when it reads a class file whose annotation classes are missing; our program does not trigger any.
$ mvn dependency:tree [INFO] com.example:words:jar:1.0.0 [INFO] \- com.google.guava:guava:jar:33.8.0-jre:compile [INFO] \- com.google.guava:failureaccess:jar:1.0.3:compile ... $ mvn -q compile exec:java -Dexec.mainClass=com.example.App an 2 old 2 in 2 the 2 sky 2
Wildcard exclusions
Both the group ID and the artifact ID of an exclusion may be the asterisk.
The combination * and * removes all transitive
dependencies of a dependency. The groupId with an asterisk in
the artifact ID removes all artifacts of a group.
<dependencies>
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>33.8.0-jre</version>
<exclusions>
<exclusion>
<groupId>*</groupId>
<artifactId>*</artifactId>
</exclusion>
</exclusions>
</dependency>
</dependencies>
This time we also exclude failureaccess, which Guava needs.
package com.example;
import com.google.common.util.concurrent.SettableFuture;
public class Tasks {
public static void main(String[] args) throws Exception {
SettableFuture<String> future = SettableFuture.create();
future.set("done");
System.out.println(future.get());
}
}
We add a second class to the project, which uses a
SettableFuture. Guava's futures extend the
InternalFutureFailureAccess class from the excluded JAR.
The tree contains only Guava. The word counting program would still run,
but the compiler cannot access the superclass of
SettableFuture. The warning shows the effect of the missing
annotation JARs mentioned earlier: SettableFuture carries a
J2ObjC annotation whose enum class cannot be found. Such warnings are
harmless. If the class were used only through reflection
or by another library, the error would appear at runtime as a
NoClassDefFoundError. Wildcard exclusions are useful for
libraries with many unwanted dependencies, but we must add back what the
code needs.
$ mvn dependency:tree [INFO] com.example:words:jar:1.0.0 [INFO] \- com.google.guava:guava:jar:33.8.0-jre:compile ... $ mvn compile [WARNING] /home/user/project/guava-wild/src/main/java/com/example/Tasks.java: unknown enum constant com.google.j2objc.annotations.ReflectionSupport.Level.FULL reason: class file for com.google.j2objc.annotations.ReflectionSupport$Level not found [ERROR] /home/user/project/guava-wild/src/main/java/com/example/Tasks.java:[8,55] cannot access com.google.common.util.concurrent.internal.InternalFutureFailureAccess class file for com.google.common.util.concurrent.internal.InternalFutureFailureAccess not found [ERROR] BUILD FAILURE ...
An exclusion affects one path
An exclusion removes an artifact from the subtree of one dependency. If the same artifact is reachable through another dependency, it stays in the graph.
<dependencies>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-text</artifactId>
<version>1.15.0</version>
<exclusions>
<exclusion>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-compress</artifactId>
<version>1.28.0</version>
</dependency>
</dependencies>
Both Commons Text and Commons Compress depend on Commons Lang. We exclude it from Commons Text only.
Commons Lang is still on the classpath, now through Commons Compress. The exclusion also changed its version. Without it, Commons Text, which is declared first, would win the mediation with version 3.20.0; now the only remaining path brings 3.18.0. The verbose tree does not show excluded artifacts, so to remove an artifact completely, we have to exclude it on every path, or use the approach shown in the next section.
$ mvn dependency:tree -Dverbose [INFO] com.example:onepath:jar:1.0.0 [INFO] +- org.apache.commons:commons-text:jar:1.15.0:compile [INFO] \- org.apache.commons:commons-compress:jar:1.28.0:compile [INFO] +- commons-codec:commons-codec:jar:1.19.0:compile [INFO] +- commons-io:commons-io:jar:2.20.0:compile [INFO] \- org.apache.commons:commons-lang3:jar:3.18.0:compile ...
Exclusions in dependency management
Exclusions can also be declared in the dependencyManagement
section. They are then applied wherever the managed artifact appears in the
graph, including the places where it is a transitive dependency.
<dependencyManagement>
<dependencies>
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>33.8.0-jre</version>
<exclusions>
<exclusion>
<groupId>org.jspecify</groupId>
<artifactId>jspecify</artifactId>
</exclusion>
<exclusion>
<groupId>com.google.errorprone</groupId>
<artifactId>error_prone_annotations</artifactId>
</exclusion>
<exclusion>
<groupId>com.google.j2objc</groupId>
<artifactId>j2objc-annotations</artifactId>
</exclusion>
</exclusions>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>com.google.inject</groupId>
<artifactId>guice</artifactId>
<version>7.0.0</version>
</dependency>
</dependencies>
The project depends on Guice, which depends on Guava. We do not use Guava directly; we only manage its version and its exclusions.
Guice 7.0.0 asks for Guava 31.0.1, but the managed version 33.8.0 is used, and the three annotation JARs are gone from Guava's subtree. In multi-module projects, such managed exclusions in the parent POM keep unwanted artifacts out of all modules in one place.
$ mvn dependency:tree [INFO] com.example:managed:jar:1.0.0 [INFO] \- com.google.inject:guice:jar:7.0.0:compile [INFO] +- jakarta.inject:jakarta.inject-api:jar:2.0.1:compile [INFO] +- aopalliance:aopalliance:jar:1.0:compile [INFO] \- com.google.guava:guava:jar:33.8.0-jre:compile [INFO] +- com.google.guava:failureaccess:jar:1.0.3:compile [INFO] \- com.google.guava:listenablefuture:jar:9999.0-empty-to-avoid-conflict-with-guava:compile ...
Optional dependencies
Exclusions are decided by the consumer of a library. The author of a library can make a similar decision in advance by marking a dependency as optional. An optional dependency is used to compile and test the library, but it is not passed on to the projects that depend on the library.
Optional dependencies are meant for features that only some users need. For instance, a database library may support several connection pools; an application uses one of them and should not get all of them. We create a small reporting library that exports data to CSV, which needs nothing, and to JSON, which needs Gson. The library and an application that uses it are two modules of one directory.
<?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>com.example</groupId>
<artifactId>reports</artifactId>
<version>1.0.0</version>
<properties>
<maven.compiler.release>25</maven.compiler.release>
</properties>
<dependencies>
<dependency>
<groupId>com.google.code.gson</groupId>
<artifactId>gson</artifactId>
<version>2.15.0</version>
<optional>true</optional>
</dependency>
</dependencies>
</project>
The Gson dependency is marked with <optional>true</optional>.
The library is compiled with Gson on the classpath.
package com.example.reports;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
public class Report {
private final List<Map<String, Object>> rows;
public Report(List<Map<String, Object>> rows) {
this.rows = rows;
}
public String toCsv() {
var header = String.join(",", rows.getFirst().keySet());
var lines = rows.stream()
.map(row -> row.values().stream().map(String::valueOf)
.collect(Collectors.joining(",")))
.collect(Collectors.joining("\n"));
return header + "\n" + lines;
}
public String toJson() {
return JsonWriter.write(rows);
}
public static boolean jsonSupported() {
try {
Class.forName("com.google.gson.Gson");
return true;
} catch (ClassNotFoundException e) {
return false;
}
}
}
The Report class produces CSV with plain Java code. The JSON
export is delegated to a separate class, so that the Report
class itself does not reference Gson.
public static boolean jsonSupported() {
try {
Class.forName("com.google.gson.Gson");
return true;
} catch (ClassNotFoundException e) {
return false;
}
}
The jsonSupported method lets users check whether the optional
dependency is present. This is a common pattern in libraries with optional
features.
package com.example.reports;
import com.google.gson.GsonBuilder;
class JsonWriter {
static String write(Object data) {
return new GsonBuilder().setPrettyPrinting().create().toJson(data);
}
}
Only the JsonWriter class uses Gson. The JVM loads it, and with
it the Gson classes, when toJson is called for the first time.
Using the library
The application depends on the reporting library and does not know about Gson.
<dependencies>
<dependency>
<groupId>com.example</groupId>
<artifactId>reports</artifactId>
<version>1.0.0</version>
</dependency>
</dependencies>
The application declares only the reports artifact.
package com.example.app;
import com.example.reports.Report;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
public class Main {
public static void main(String[] args) {
var report = new Report(List.of(
row("Bratislava", 475_000),
row("Budapest", 1_686_000)));
System.out.println(report.toCsv());
System.out.println("JSON supported: " + Report.jsonSupported());
System.out.println(report.toJson());
}
static Map<String, Object> row(String city, int population) {
var row = new LinkedHashMap<String, Object>();
row.put("city", city);
row.put("population", population);
return row;
}
}
The program prints a report as CSV, checks the JSON support, and prints the report as JSON.
We install the library into the local repository and run the application
from its directory. Gson is missing from the tree of the application. The CSV
export works, jsonSupported returns false, and the call to
toJson fails with a NoClassDefFoundError.
$ mvn -q install $ mvn dependency:tree [INFO] com.example:app:jar:1.0.0 [INFO] \- com.example:reports:jar:1.0.0:compile ... $ mvn compile exec:java -Dexec.mainClass=com.example.app.Main city,population Bratislava,475000 Budapest,1686000 JSON supported: false java.lang.NoClassDefFoundError: com/google/gson/GsonBuilder Caused by: java.lang.ClassNotFoundException: com.google.gson.GsonBuilder [ERROR] BUILD FAILURE ...
Adding the optional dependency
To use the optional feature, the application declares the optional dependency itself. The library author has to document which optional dependencies enable which features, because Maven does not record this anywhere.
<dependencies>
<dependency>
<groupId>com.example</groupId>
<artifactId>reports</artifactId>
<version>1.0.0</version>
</dependency>
<dependency>
<groupId>com.google.code.gson</groupId>
<artifactId>gson</artifactId>
<version>2.15.0</version>
</dependency>
</dependencies>
Gson is now a regular dependency of the application.
With Gson on the classpath, both exports work. An optional dependency
behaves like a dependency with the provided scope in that it is
not transitive; the difference is that a provided dependency is
expected from the runtime environment, such as an application server, while
an optional one is chosen by the user of the library.
$ mvn -q compile exec:java -Dexec.mainClass=com.example.app.Main
city,population
Bratislava,475000
Budapest,1686000
JSON supported: true
[
{
"city": "Bratislava",
"population": 475000
},
{
"city": "Budapest",
"population": 1686000
}
]
Version conflicts in practice
Exclusions remove artifacts; version conflicts are a different problem. When the graph contains several versions of one artifact, Maven chooses the version nearest to the project, not the highest one. A library can then run with an older version than the one it was compiled against.
We demonstrate the problem with three small artifacts. Version 1.0.0 of the
textutil library has one method; version 2.0.0 adds another one.
package com.example.textutil;
public class Strings {
public static String capitalize(String s) {
return s.isEmpty() ? s : Character.toUpperCase(s.charAt(0)) + s.substring(1);
}
public static String slugify(String s) {
return s.toLowerCase().replaceAll("[^a-z0-9]+", "-").replaceAll("^-|-$", "");
}
}
The slugify method is new in version 2.0.0. Version 1.0.0
contains only capitalize.
package com.example.blog;
import com.example.textutil.Strings;
public class Links {
public static String postLink(String title) {
return "/posts/" + Strings.slugify(title);
}
}
The blog library depends on textutil 2.0.0 and
calls slugify.
<dependencies>
<dependency>
<groupId>com.example</groupId>
<artifactId>blog</artifactId>
<version>1.0.0</version>
</dependency>
<dependency>
<groupId>com.example</groupId>
<artifactId>textutil</artifactId>
<version>1.0.0</version>
</dependency>
</dependencies>
The application uses the blog library and also declares
textutil 1.0.0 directly; perhaps the direct dependency was
added before the blog library and nobody updated it.
package com.example.app;
import com.example.blog.Links;
import com.example.textutil.Strings;
public class Main {
public static void main(String[] args) {
var title = "maven dependency conflicts, explained";
System.out.println(Strings.capitalize(title));
System.out.println(Links.postLink(title));
}
}
The application calls capitalize directly and
slugify through the blog library.
The direct dependency is nearer, so version 1.0.0 wins. The application
compiles, because its own code uses only capitalize. At runtime,
the blog library calls a method that does not exist in the
loaded class, and the JVM throws a NoSuchMethodError. Similar
errors are NoClassDefFoundError and
AbstractMethodError; all of them suggest a version conflict.
$ mvn dependency:tree -Dverbose [INFO] com.example:app:jar:1.0.0 [INFO] +- com.example:blog:jar:1.0.0:compile [INFO] | \- (com.example:textutil:jar:2.0.0:compile - omitted for conflict with 1.0.0) [INFO] \- com.example:textutil:jar:1.0.0:compile ... $ mvn compile exec:java -Dexec.mainClass=com.example.app.Main Maven dependency conflicts, explained java.lang.NoSuchMethodError: 'java.lang.String com.example.textutil.Strings.slugify(java.lang.String)' [ERROR] BUILD FAILURE ...
Detecting conflicts
The Enforcer plugin can check the resolved graph during the build. The
requireUpperBoundDeps rule fails when a resolved version is
lower than a version requested somewhere else in the graph.
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-enforcer-plugin</artifactId>
<version>3.7.0</version>
<executions>
<execution>
<id>enforce</id>
<goals>
<goal>enforce</goal>
</goals>
<configuration>
<rules>
<requireUpperBoundDeps/>
</rules>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
The enforce goal is bound to the validate phase by
default, so it runs at the start of every build. The stricter
dependencyConvergence rule fails whenever two paths request
different versions of an artifact, even if the higher version wins.
The build fails before compilation. The rule lists both paths and suggests a
dependencyManagement entry that fixes the conflict.
$ mvn verify [ERROR] BUILD FAILURE [ERROR] Rule 0: org.apache.maven.enforcer.rules.dependency.RequireUpperBoundDeps failed with message: [ERROR] Require upper bound dependencies error for com.example:textutil:1.0.0. Paths to dependency are: [ERROR] +-com.example:app:1.0.0 [ERROR] +-com.example:textutil:1.0.0 [ERROR] and [ERROR] +-com.example:app:1.0.0 [ERROR] +-com.example:blog:1.0.0 [ERROR] +-com.example:textutil:2.0.0 [ERROR] To fix this, add the following to your <dependencyManagement> section: [ERROR] <dependencyManagement> [ERROR] <dependencies> [ERROR] <dependency> [ERROR] <groupId>com.example</groupId> [ERROR] <artifactId>textutil</artifactId> [ERROR] <version>2.0.0</version> [ERROR] </dependency> [ERROR] </dependencies> [ERROR] </dependencyManagement> ...
Fixing the conflict
We fix the version in the dependencyManagement section, as
suggested. The direct dependency then omits its version.
<dependencyManagement>
<dependencies>
<dependency>
<groupId>com.example</groupId>
<artifactId>textutil</artifactId>
<version>2.0.0</version>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>com.example</groupId>
<artifactId>blog</artifactId>
<version>1.0.0</version>
</dependency>
<dependency>
<groupId>com.example</groupId>
<artifactId>textutil</artifactId>
</dependency>
</dependencies>
The managed version applies to the direct dependency and to the transitive one. In a single project, raising the version of the direct dependency would be enough; in a multi-module project, the managed version in the parent POM or in a BOM keeps all modules in agreement.
Excluding textutil from the blog dependency would
silence the Enforcer as well, but it would keep the old version and the
broken call. Exclusions remove artifacts; they are the wrong tool for
choosing versions.
Both paths now resolve to version 2.0.0, the Enforcer rule passes, and the application runs.
$ mvn dependency:tree -Dverbose [INFO] com.example:app:jar:1.0.0 [INFO] +- com.example:blog:jar:1.0.0:compile [INFO] | \- (com.example:textutil:jar:2.0.0:compile - version managed from 2.0.0; omitted for duplicate) [INFO] \- com.example:textutil:jar:2.0.0:compile ... $ mvn -q verify exec:java -Dexec.mainClass=com.example.app.Main Maven dependency conflicts, explained /posts/maven-dependency-conflicts-explained
Best practices
- Inspect the graph with
mvn dependency:tree -Dverbosebefore and after adding an exclusion. - Exclude only what is really unused or replaced, and check that the application still runs; wildcard exclusions remove required libraries as easily as unwanted ones.
- To remove an artifact from the whole graph, declare the exclusion in
dependencyManagementrather than on every path. - In libraries, mark dependencies of optional features as optional, keep the code that uses them in separate classes, and document which dependency enables which feature.
- Choose versions with
dependencyManagementor a BOM, not with exclusions, and let the Enforcer plugin'srequireUpperBoundDepsordependencyConvergencerule catch conflicts early.
Source
Optional dependencies and dependency exclusions
In this article we have used exclusions and optional dependencies to control the transitive dependencies of Maven projects, and we have found and fixed a version conflict.
Author
List all Java tutorials.