Developer Productivity Engineering Blog

Maven dependency hell: Five tips to get out

Dealing with dependencies is an inevitable part of developing Java applications with any build tool, and Apache Maven is no exception. As your project grows, the complexity of managing these dependencies can lead to the dreaded “dependency hell,” where conflicts, version mismatches, and unresolvable libraries disrupt your build. The more dependencies added to your project, the greater the risk of these disruptive conflicts, which can stall development and complicate maintenance.

In this post, we’ll explore five strategic approaches for managing dependencies effectively in Maven. Each one will help you escape one of the levels of Dante’s Inferno dependency hell.

What is dependency hell?

“Dependency hell” is a common headache for developers—it describes the situation wherein you attempt to update a single piece of software and end up in a downward spiral of chaos.

Imagine you’re updating the logging framework in your app to the latest version to take advantage of a new feature. As soon as you do, your HTTP client library stops working because it needs an older version of the logging library. Or worse, your project compiles, but your tests fail with a cryptic NoSuchMethodException.

So you roll back the update, but your email service module begins acting up because it was fine with the newer version. It’s like trying to update your phone only to discover that some of your favorite apps no longer work with the new OS. Every attempt to fix one problem seems to create several new ones, leaving you playing a frustrating game of software tug-of-war.

The best way to “fix” dependency hell is to avoid getting into it in the first place. So, what steps can you take proactively to avert a situation like the one I just described? Here are five different ways you can go about it. While I believe following each of these recommendations on its own will prove useful, employing all five will put you in the best possible position.

1. Use dependency management

Efficient dependency management is crucial in any Maven project, particularly when dealing with multi-module projects. The dependency management feature in Maven’s pom.xml allows you to declare and manage versions of dependencies being used across all modules in a single, unified location.

A simplistic explanation of dependency management is that it provides a default value for the version of the matching dependency when later used in a POM.

Setting up dependency management

Include a dependency management section in your project’s parent pom.xml. This section dictates which versions of dependencies should be used project-wide without the need to specify them in each module:

<dependencyManagement>
  <dependencies>
    <dependency>
      <groupId>org.springframework</groupId>
      <artifactId>spring-core</artifactId>
      <version>5.3.10</version>
    </dependency>
    <dependency>
      <groupId>org.apache.commons</groupId>
      <artifactId>commons-lang3</artifactId>
      <version>3.12.0</version>
    </dependency>
  </dependencies>
</dependencyManagement>

In this example, the default version for spring-core is 5.3.10, and the default version for commons-lang3 is 3.12.0. These dependencies are not added to a project until they are defined in a regular dependencies section. For example:

<dependencies>
  <dependency>
    <groupId>org.springframework</groupId>
    <artifactId>spring-core</artifactId>
  </dependency>
  <dependency>
    <groupId>org.apache.commons</groupId>
    <artifactId>commons-lang3</artifactId>
  </dependency>
</dependencies>

When Maven reads the pom, it will see that the versions are missing and look them up in the dependency management section, which would evaluate to the equivalent block in memory.

<dependencies>
  <dependency>
    <groupId>org.springframework</groupId>
    <artifactId>spring-core</artifactId>
    <version>5.3.10</version>
  </dependency>
  <dependency>
    <groupId>org.apache.commons</groupId>
    <artifactId>commons-lang3</artifactId>
    <version>3.12.0</version>
  </dependency>
</dependencies>

NOTE: Dependency Management can also be used for the value of the scope field, but I’d recommend against this, as others may not realize the default has been changed from the normal default scope value of compile.

Tips and tricks for dependency management

You can force the use of a newer version of a transitive dependency by defining the version in dependency management. For example, if your project uses Spring Boot and you need to update one of its dependencies because of a newly announced vulnerability (or other needed functionality) and you can’t wait for a Spring Boot build, you could add the transitive dependency in your pom.

The following example updates the micrometer-observation used from 1.12.0 to 1.13.0 — this dependency does NOT need to be declared elsewhere in a pom, as it is a transitive dependency of Spring Boot Web.

<dependencyManagement>
  <dependencies>
    <dependency>
      <groupId>io.micrometer</groupId>
      <artifactId>micrometer-observation</artifactId>
      <version>1.13.0</version>
    </dependency>
  </dependencies>
</dependencyManagement>

<dependencies>
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-web</artifactId>
      <version>3.2.0</version>
    </dependency>
</dependencies>

NOTE: At the time of this writing, micrometer-observation does NOT have any known vulnerabilities; it is only used as an example of updating the version of a transitive dependency.

2. Understand your Maven dependency tree

Understanding your dependencies is essential for managing them effectively. The Maven Dependency Plugin’s “tree” goal allows you to visualize your project’s dependency tree.

This command shows a hierarchical view of the dependency relationships in your project, making it easier to spot conflicts and redundancies.

How to use the Maven Dependency Plugin “tree”:

Run the goal directly from the command line:

mvn dependency:tree

This will output the structure of your project’s dependencies, which can be used for spotting where conflicting versions might clash. The following is an example of a project that depends on the JJWT library:

[INFO] --- dependency:3.6.0:tree (default-cli) @ demo ---
[INFO] com.example:demo:jar:0.0.1-SNAPSHOT
[INFO] \- io.jsonwebtoken:jjwt:jar:0.12.1:compile
[INFO]    +- io.jsonwebtoken:jjwt-api:jar:0.12.1:compile
[INFO]    +- io.jsonwebtoken:jjwt-impl:jar:0.12.1:runtime
[INFO]    \- io.jsonwebtoken:jjwt-jackson:jar:0.12.1:runtime
[INFO]       \- com.fasterxml.jackson.core:jackson-databind:jar:2.12.7.1:runtime
[INFO]          +- com.fasterxml.jackson.core:jackson-annotations:jar:2.12.7:runtime
[INFO]          \- com.fasterxml.jackson.core:jackson-core:jar:2.12.7:runtime

From this run of dependency:tree, you can see that Jackson Databind 2.17.7, is a transitive dependency of JJWT.

3. Use the Enforcer Plugin

The Maven Enforcer Plugin is a powerful tool for codifying rules in your project. It can ban duplicate dependencies, enforce specific versions, ensure your project meets various criteria, and even run custom rules. This helps keep your dependency structure clean and manageable.

How to configure the Maven Enforcer Plugin

To use the Enforcer Plugin, add its plugin definition and configuration to your pom.xml. In the example below, I’ve added two rules:

  • The “banned dependency” rule will fail if an older version of commons-logging is used by this project (as a direct dependency or a transitive dependency).
  • The “dependency convergence” rule will fail if multiple dependency versions are resolved in the dependency graph.
<build>
  <plugins>
    <plugin>
      <groupId>org.apache.maven.plugins</groupId>
      <artifactId>maven-enforcer-plugin</artifactId>
      <version>3.4.1</version>
      <executions>
        <execution>
          <goals>
            <goal>enforce</goal>
          </goals>
          <configuration>
            <rules>
              <!-- Fail build if commons-loggin < 1.3 used -->
              <bannedDependencies>
                <excludes>
                  <exclude>commons-logging:commons-logging:(,1.3)</exclude>
                </excludes>
              </bannedDependencies>

              <!-- Only the single XML element is needed -->
              <dependencyConvergence />
            </rules>
          </configuration>
        </execution>
      </executions>
    </plugin>
  </plugins>
</build>

An example of a dependency convergence failure could be: library-x depends on Log4j version 2.23.1, and library-y depends on version 2.22.1. To fix this failure, you could define the Log4j version in dependency management (see above).

4. Keep dependencies to a minimum

The idea is simple—the fewer dependencies you have, the fewer potential conflicts exist. There is also a security benefit: fewer dependencies should lessen the surface area of your application.

I want to note here that I’m not advocating replacing the use of a library with something you build yourself, or copying code from somewhere else—I’m just encouraging you to be aware of the libraries you’re already including in your application because you might already have something that fits your needs.

If you’re building a Spring application, you probably don’t need to include Guava to check if a string is empty with Strings.isNullOrEmpty(), instead, you could use Spring’s StringUtils.hasLength() method. Similarly, if you need to parse JSON and already have Jackson on your classpath, you likely shouldn’t use GSON or org.json.

5. Regularly update your dependencies

Keeping dependencies up-to-date is critical to avoiding the buildup of dependencies that leads to dependency hell. Newer versions of libraries often resolve bugs, security vulnerabilities, and dependency conflicts that were present in older versions.

By updating your dependencies regularly, you have a higher chance of catching any issues early. This puts you in a better position than waiting until the end of a release to update all your dependencies simultaneously.

How to keep your dependencies up-to-date

Luckily, keeping your dependencies up-to-date has never been easier—there are many different bots that will submit pull requests to your project when there are updates available. To name just a few:

However, you can also manually list any dependencies that have newer versions by using the Versions Maven Plugin. Run:

mvn versions:display-dependency-updates

NOTE: Having a good test suite is critical when updating your dependencies, especially when using a bot or automated process.

Bonus tip: Use Develocity Build Scan® (free)

A Build Scan is like an X-ray for your build. It provides deep insights into everything related to your build, including dependency resolution, build performance, and potential issues. By using Build Scan, you can quickly diagnose and fix problems related to dependencies and other build problems.

You can use the Build Scan service for free on scans.gradle.com. To enable Build Scan, add the Develocity Maven Extension to your project by running the following command:

mvn com.gradle:gradle-enterprise-maven-extension:1.21.4:init

After that, run your build as usual, mvn clean install, ./mvnw verify, or any other goal you would normally run. At the end of your build, you will see a URL at the end of your console output that looks something like this:

[INFO]
[INFO] Publishing build scan...
[INFO] https://gradle.com/s/fl6dxescemy64
[INFO]

Open the URL in your browser. There is a lot of information in a Build Scan, but to help solve your dependency hell issues, you can open Dependencies from the left navigation and look at your project’s dependency graph. This shows all of the information mentioned above when using the Dependency Plugin’s “tree” goal in a searchable interface that is automatically captured for all of your builds!

If you want to learn more about how Build Scan can help you troubleshoot your build issues, sign up for the free Build Scan course from DPE University.

Learn more ways to troubleshoot Maven builds

Navigating dependency hell is just one aspect of managing Maven builds effectively. There are many other things to consider, such as performance optimization, test reliability, and streamlining processes. To learn more, check out these related resources:

For more great content, follow us on Twitter and LinkedIn or subscribe to our YouTube channel.