Certain combinations of transitive dependencies in a Java project cause compile and runtime issues. Fortunately, Gradle has several ways to exclude those unwanted dependencies from the Java classpath to fix your project. In this article you’ll learn the common reasons you’d want to exclude dependencies and how to use each of Gradle’s exclude strategies.
Why Exclude Dependencies?
When you declare a dependency in your build script, Gradle automatically pulls any transitive dependencies (dependencies of that dependency) into your project. In Java projects, these dependencies are added to the compile and runtime classpaths.
Let’s explore some scenarios where certain combinations of dependencies can cause an issue in your project.
1. Multiple SLF4J bindings
The SLF4J logging library requires that only one binding (i.e. implementation) appears on the classpath, otherwise it doesn’t know which to use for logging. For example, if you include both the Logback and Log4J bindings you get this runtime error.
SLF4J: Class path contains multiple SLF4J bindings.
This means one of those bindings must be removed from the classpath, which you’ll see how to do shortly using exclude rules.
2. Unused transitive dependency
Sometimes we only need to use a small part of a dependency. One or more of its transitive dependencies may not be needed at compile or runtime.
For example, Google’s popular Guava utility library pulls in several transitive dependencies, such as com.google.code.findbugs:jsr305 (which we’ll simply call findbugs). If we only use one specific method from Guava that we’re confident doesn’t use findbugs, then findbugs is a candidate for exclusion from the Java classpath.
Of course, when taking this approach we must be confident that the excluded library won’t be required now or in the future. This can be verified with automated tests to exercise the area of code that uses the dependency.
The benefits of excluding unused transitive dependencies are twofold:
- a cleaner compile classpath which can improve performance
- a smaller application deployable due to less artifacts on the runtime classpath
Now you know what problems transitive dependencies can cause, let’s explore 3 ways in which Gradle excludes them.
Option 1) Per-Dependency Exclude Rule
When you specify a dependency in your build script, at the same time you can provide an exclude rule telling Gradle not to pull in a specific transitive dependency.
For example, say we have a Gradle project that depends on Google’s Guava library with co-ordinates com.google.guava:guava:32.1.3-jre
.
Here’s the build script dependency.
dependencies {
implementation("com.google.guava:guava:32.1.3-jre")
}
dependencies {
implementation 'com.google.guava:guava:32.1.3-jre'
}
Inspecting the compile and runtime classpaths shows these transitive dependencies.
\--- com.google.guava:guava:32.1.3-jre
+--- com.google.guava:failureaccess:1.0.1
+--- com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava
+--- com.google.code.findbugs:jsr305:3.0.2
+--- org.checkerframework:checker-qual:3.37.0
\--- com.google.errorprone:error_prone_annotations:2.21.1
Guava pulls in a lot of extra stuff! You’ll see later how to generate such a dependency graph yourself.
Imagine we just want to use a tiny subset of Guava, like the endlessly helpful ImmutableMap.of(K k1, V v1)
method.
That means we can exclude unnecessary transitive dependencies like findbugs.
Here’s the syntax for an appropriate exclude rule.
dependencies {
implementation("com.google.guava:guava:32.1.3-jre") {
exclude(group = "com.google.code.findbugs", module = "jsr305")
}
}
dependencies {
implementation('com.google.guava:guava:32.1.3-jre') {
exclude group: 'com.google.code.findbugs', module: 'jsr305'
}
}
In the closure we call exclude
, passing:
group
: the group of the artifact we want to excludemodule
: the name of the artifact we want to exclude. This is the same as thename
used to declare a dependency.
It’s also valid to pass only group
or only module
to match more generically.
In our example though, any combination of group/module results in this updated list of transitive dependencies.
\--- com.google.guava:guava:32.1.3-jre
+--- com.google.guava:failureaccess:1.0.1
+--- com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava
+--- org.checkerframework:checker-qual:3.37.0
\--- com.google.errorprone:error_prone_annotations:2.21.1
So we now have one less dependency. Findbugs is no more! 🔫
A caveat
The problem with adding an exclude at the dependency level (i.e. per-dependency) is if another dependency also pulls in the excluded dependency, the exclude is ignored.
Continuing the above example, imagine a well-meaning colleague decides to add another dependency on jackson-datatype-guava.
Our build script dependencies now look like this.
dependencies {
implementation("com.google.guava:guava:32.1.3-jre") {
exclude(group = "com.google.code.findbugs", module = "jsr305")
}
implementation("com.fasterxml.jackson.datatype:jackson-datatype-guava:2.15.3")
}
dependencies {
implementation('com.google.guava:guava:32.1.3-jre') {
exclude group: 'com.google.code.findbugs', module: 'jsr305'
}
implementation 'com.fasterxml.jackson.datatype:jackson-datatype-guava:2.15.3'
}
On the surface this seems like an innocent change, but look at the classpaths now.
+--- com.google.guava:guava:32.1.3-jre
| +--- com.google.guava:failureaccess:1.0.1
| +--- com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava
| +--- com.google.code.findbugs:jsr305:3.0.2
| +--- org.checkerframework:checker-qual:3.37.0
| +--- com.google.errorprone:error_prone_annotations:2.21.1
| \--- com.google.j2objc:j2objc-annotations:2.8
\--- com.fasterxml.jackson.datatype:jackson-datatype-guava:2.15.3
+--- com.google.guava:guava:23.6.1-jre -> 32.1.3-jre (*)
... truncated for clarity
Despite our exclude rule, findbugs has sneaked its way back in again! 😩 That’s because jackson-datatype-guava depends on guava, meaning Gradle pulls in all of guava’s transitive dependencies again.
The upside to this functionality is that it makes us think carefully how an exclusion applies to each dependency. If we’re confident that jackson-datatype-guava also doesn’t use findbugs, we can add another exclude rule to its dependency definition.
But what if there’s a dependency we’re ABSOLUTELY sure should never be included? Can we end this game of whack-a-mole permanently?
Fortunately Gradle has another trick up its sleeve…
Option 2) Per-Configuration Exclude Rule
When we’re confident a transitive dependency should be excluded from all dependencies, Gradle offers exclude rules per dependency configuration.
Let’s use the earlier example, with an implementation dependency on guava, which transitively depends on findbugs.
Here’s the syntax for the per-configuration exclude rule.
dependencies {
implementation("com.google.guava:guava:32.1.3-jre")
}
configurations.implementation {
exclude(group = "com.google.code.findbugs", module = "jsr305")
}
dependencies {
implementation 'com.google.guava:guava:32.1.3-jre'
}
configurations.implementation {
exclude group: 'com.google.code.findbugs', module: 'jsr305'
}
This time we pass a closure to the dependency configuration. Similarly, in the closure we call exclude
passing a group
and/or module
.
With only the per-configuration exclude rule applied, here are the compile and runtime classpath dependencies.
\--- com.google.guava:guava:32.1.3-jre
+--- com.google.guava:failureaccess:1.0.1
+--- com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava
+--- org.checkerframework:checker-qual:3.37.0
\--- com.google.errorprone:error_prone_annotations:2.21.1
Findbugs was successfully squished again! Even if we add other dependencies which transitively depend on findbugs, it won’t appear on our classpaths because the exclude rule is per-configuration.
An SLF4J + Spring Boot example
Consider a Spring Boot web application in which we want to use the SLF4J logging framework with a Log4j2 implementation. The project’s build script has the following dependencies.
dependencies {
implementation("org.springframework.boot:spring-boot-starter-web:3.1.5")
implementation("org.springframework.boot:spring-boot-starter-log4j2:3.1.5")
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web:3.1.5'
implementation 'org.springframework.boot:spring-boot-starter-log4j2:3.1.5'
}
The application contains this Java code to get and use a logger, using only the SLF4J APIs.
package com.tomgregory;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class LoggingExample {
public static void main(String[] args) {
Logger logger = LoggerFactory.getLogger(LoggingExample.class);
logger.error("Error level output");
}
}
When we run the application there are 2 problems:
-
we get the SLF4J: Class path contains multiple SLF4J bindings warning mentioned earlier
-
when we try to get an instance of
org.slf4j.Logger
we get an exception
org.apache.logging.log4j.LoggingException: log4j-slf4j2-impl cannot be present with log4j-to-slf4j
Let’s deal with the exception first.
Here are the runtime classpath dependencies (...
indicates entries left out for clarity).
+--- org.springframework.boot:spring-boot-starter-web:3.1.5
| +--- org.springframework.boot:spring-boot-starter:3.1.5
| | +--- ...
| | +--- org.springframework.boot:spring-boot-starter-logging:3.1.5
| | | +--- ch.qos.logback:logback-classic:1.4.11
| | | | +--- ch.qos.logback:logback-core:1.4.11
| | | | \--- org.slf4j:slf4j-api:2.0.7 -> 2.0.9
| | | +--- org.apache.logging.log4j:log4j-to-slf4j:2.20.0
| | | | +--- org.apache.logging.log4j:log4j-api:2.20.0
| | | | \--- org.slf4j:slf4j-api:1.7.36 -> 2.0.9
| | | \--- org.slf4j:jul-to-slf4j:2.0.9
| | | \--- org.slf4j:slf4j-api:2.0.9
| | +--- ...
| +--- ...
\--- org.springframework.boot:spring-boot-starter-log4j2:3.1.5
+--- org.apache.logging.log4j:log4j-slf4j2-impl:2.20.0
| +--- org.apache.logging.log4j:log4j-api:2.20.0
| +--- org.slf4j:slf4j-api:2.0.6 -> 2.0.9
| \--- org.apache.logging.log4j:log4j-core:2.20.0
| \--- org.apache.logging.log4j:log4j-api:2.20.0
+--- org.apache.logging.log4j:log4j-core:2.20.0 (*)
\--- org.apache.logging.log4j:log4j-jul:2.20.0
\--- org.apache.logging.log4j:log4j-api:2.20.0
Highlighted above are dependencies listed in the exception message (log4j-to-slf4j and log4j-slf4j-impl).
According to the log4j-to-slf4j docs, this framework “allows applications coded to the Log4j 2 API to be routed to SLF4J”.
So we can safely exclude this dependency as we won’t be calling the Log4j 2 APIs from our application class. Only the SLF4J APIs.
Here’s the per-configuration exclude rule.
dependencies {
implementation("org.springframework.boot:spring-boot-starter-web:3.1.5")
implementation("org.springframework.boot:spring-boot-starter-log4j2:3.1.5")
}
configurations.implementation {
exclude(group = "org.apache.logging.log4j", module = "log4j-to-slf4j")
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web:3.1.5'
implementation 'org.springframework.boot:spring-boot-starter-log4j2:3.1.5'
}
configurations.implementation {
exclude group: 'org.apache.logging.log4j', module: 'log4j-to-slf4j'
}
This fixes the exception, so the logging framework can now be used at runtime. But we still get an unfriendly warning on startup.
SLF4J: Class path contains multiple SLF4J providers.
SLF4J: Found provider [org.apache.logging.slf4j.SLF4JServiceProvider@5ce65a89]
SLF4J: Found provider [ch.qos.logback.classic.spi.LogbackServiceProvider@25f38edc]
SLF4J: See https://www.slf4j.org/codes.html#multiple_bindings for an explanation.
SLF4J: Actual provider is of type [org.apache.logging.slf4j.SLF4JServiceProvider@5ce65a89]
20:37:29.711 [main] ERROR com.tomgregory.Application - Logging class: class org.apache.logging.slf4j.Log4jLogger
So it’s finding bindings for both log4j and logback. Let’s review the runtime classpath dependencies again.
+--- org.springframework.boot:spring-boot-starter-web:3.1.5
| +--- org.springframework.boot:spring-boot-starter:3.1.5
| | +--- ...
| | +--- org.springframework.boot:spring-boot-starter-logging:3.1.5
| | | +--- ch.qos.logback:logback-classic:1.4.11
| | | | +--- ch.qos.logback:logback-core:1.4.11
| | | | \--- org.slf4j:slf4j-api:2.0.7 -> 2.0.9
| | | \--- org.slf4j:jul-to-slf4j:2.0.9
| | | \--- org.slf4j:slf4j-api:2.0.9
| | +--- ...
| +--- ...
\--- org.springframework.boot:spring-boot-starter-log4j2:3.1.5
+--- org.apache.logging.log4j:log4j-slf4j2-impl:2.20.0
| +--- org.apache.logging.log4j:log4j-api:2.20.0
| +--- org.slf4j:slf4j-api:2.0.6 -> 2.0.9
| \--- org.apache.logging.log4j:log4j-core:2.20.0
| \--- org.apache.logging.log4j:log4j-api:2.20.0
+--- org.apache.logging.log4j:log4j-core:2.20.0 (*)
\--- org.apache.logging.log4j:log4j-jul:2.20.0
\--- org.apache.logging.log4j:log4j-api:2.20.0
When using the SLF4J logging framework, the runtime classpath should only contain a single binding to a logging implementation. The line highlighted above shows that spring-boot-starter-logging is bringing in logback-classic, which we don’t want.
But rather than exclude just logback-classic, we can actually exclude the whole spring-boot-starter-logging dependency in favour of spring-boot-starter-log4j2. This also has the advantage of making our previous exclude rule redundant, as log4j-to-slf4j is pulled in by spring-boot-starter-logging.
Here’s the updated exclude rule.
dependencies {
implementation("org.springframework.boot:spring-boot-starter-web:3.1.5")
implementation("org.springframework.boot:spring-boot-starter-log4j2:3.1.5")
}
configurations.implementation {
exclude(group = "org.springframework.boot", module = "spring-boot-starter-logging")
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web:3.1.5'
implementation 'org.springframework.boot:spring-boot-starter-log4j2:3.1.5'
}
configurations.implementation {
exclude group: 'org.springframework.boot', module: 'spring-boot-starter-logging'
}
No warnings on startup now. 👍
It’s good to know this is the approach recommended by the Spring Boot Logging docs.
Exclude dependency from all configurations
Excluding the dependency from the implementation dependency configuration is enough to fix the Spring Boot + SLF4J error. In fact, this excludes the dependency from the compile, runtime, testCompile, and testRuntime classpaths!
But, Gradle offers a way to exclude dependencies from all dependency configurations. As an example, this could be helpful if you have the same dependency on the annotationProcessor path and want to exclude it.
Here’s the syntax to exclude spring-boot-starter-logging from all dependency configurations.
configurations.all {
exclude(group = "org.springframework.boot", module = "spring-boot-starter-logging")
}
configurations.all {
exclude group: 'org.springframework.boot', module: 'spring-boot-starter-logging'
}
Option 3) Module Replacement Exclude Alternative
One alternative suggested in the Spring Boot documentation is to replace rather than exclude a dependency.
Here’s how to replace spring-boot-starter-logging with org.springframework.boot:spring-boot-starter-log4j2.
dependencies {
implementation("org.springframework.boot:spring-boot-starter-web:3.1.5")
implementation("org.springframework.boot:spring-boot-starter-log4j2:3.1.5")
modules {
module("org.springframework.boot:spring-boot-starter-logging") {
replacedBy(
"org.springframework.boot:spring-boot-starter-log4j2",
"Use Log4J2 instead of Logback"
)
}
}
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web:3.1.5'
implementation 'org.springframework.boot:spring-boot-starter-log4j2:3.1.5'
modules {
module('org.springframework.boot:spring-boot-starter-logging') {
replacedBy 'org.springframework.boot:spring-boot-starter-log4j2', 'Use Log4j2 instead of Logback'
}
}
}
Note the <group>:<module>
format of the string passed to the module method and then as the first argument to replacedBy.
You can also provide a replacement reason to help with troubleshooting and documentation.
The module replacement is more explicit than an exclude rule, as it ties the dependency to be excluded and the replacement together in one place. In terms of the generated classpaths, the end result is the same.
Review Your Project’s Dependencies
Check the dependencies in your own project using Gradle’s built-in dependencies task.
Here’s how to review dependencies across all dependency configurations:
./gradlew dependencies
To review dependencies for a specific dependency configuration, use this.
./gradlew dependencies --configuration <dependency-configuration-name>
The most important dependency configurations to plug into the above command are compileClasspath and runtimeClasspath.
All the dependency graphs in this article were generated with this technique. For a full understanding of the dependencies task, as well as other troubleshooting options available in Gradle, sign up to the Gradle Hero course.
Common Pitfalls
Here are some common problems you might encounter trying to exclude dependencies.
Trying to exclude with name
instead of module
For reasons still unknown, when specifying an exclude in Gradle you pass a module
key instead of the name
key used to specify a dependency.
They represent the same value—the name/id of the artifact.
Here’s the INVALID syntax.
configurations.implementation {
exclude(group = "org.springframework.boot", name = "spring-boot-starter-logging")
}
configurations.implementation {
exclude group: 'org.springframework.boot', name: 'spring-boot-starter-logging'
}
Which produces this error on build.
Cannot find a parameter with this name: name
It’s easily fixed by replacing name
with module
.
configurations.implementation {
exclude(group = "org.springframework.boot", module = "spring-boot-starter-logging")
}
configurations.implementation {
exclude group: 'org.springframework.boot', module: 'spring-boot-starter-logging'
}
Trying to exclude using string instead of map notation
Another difference between using the exclude
method and declaring dependencies is that you can’t exclude a dependency using string notation e.g. <group>:<module>
.
Here’s an example of an INVALID build script exclusion rule.
configurations.implementation {
exclude("org.springframework.boot:spring-boot-starter-logging")
}
configurations.implementation {
exclude 'org.springframework.boot:spring-boot-starter-logging'
}
Gradle interprets the group as invalid so doesn’t exclude the dependency as intended.
Fix this by replacing the string with a map.
configurations.implementation {
exclude(group = "org.springframework.boot", name = "spring-boot-starter-logging")
}
configurations.implementation {
exclude group: 'org.springframework.boot', module: 'spring-boot-starter-logging'
}
Gradle Exclude Dependency Comparison
Approach name | Description | When to use | How to specify |
---|---|---|---|
Per-dependency exclude | Add an exclude rule to a specific dependency | You want to exclude a transitive dependency from one specific dependency, but not necessarily if it gets pulled in by another. | Map of group and/or module |
Per-configuration exclude | Add an exclude rule to an entire dependency configuration or all dependency configurations | You’re sure you want to exclude the transitive dependency across all dependencies. | Map of group and/or module |
Module replacement | Replace one dependency with another | You know that whenever there’s one dependency, it should always be replaced with another. | String of <group>:<module> |
Resources
Try out the SLF4J + Spring Boot example from this article by checking out the accompanying GitHub repository.
Missing Gradle knowledge makes you slow
A broken build is easy to fix with a quick Google search, but a misconfigured build costs hours of time each week.
Time to build. Time to test. Time to fix.
Knowing the "right" way to build a project isn't obvious, especially with pages of hard-to-follow documentation. But for your project to scale, you must master the build script and configure it effectively.
That's now a lot easier with Gradle Build Bible.