Dependencies Management

How Maven resolves transitive dependencies β€” and what to do when it resolves them wrong

← Back to Index

What Are Transitive Dependencies?

When you declare a dependency on library A, and A itself depends on library B, Maven automatically resolves and downloads B too β€” you never declare B yourself. This is transitive dependency resolution, and it's what makes a single spring-boot-starter-web declaration pull in dozens of actual JARs.

The problem it creates: in any real project, multiple libraries transitively depend on different versions of the same artifact. Two different libraries might each want a different version of jackson-databind. Maven has to pick exactly one β€” this page is about understanding how it picks, and how you take control when it picks wrong. For basic dependency syntax and scopes, see Dependency Scopes; for the full POM structure, see POM.xml Structure.

// You declare ONE dependency...
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>

// ...and transitively get dozens, including:
//   spring-web, spring-webmvc, jackson-databind, tomcat-embed-core,
//   spring-boot-starter, spring-boot-starter-json, spring-boot-starter-tomcat...
// Run `mvn dependency:tree` to see the full resolved list.

How Maven Resolves Version Conflicts

When the dependency graph contains two different versions of the same artifact, Maven applies "nearest definition wins" β€” not "newest version wins", which surprises almost everyone the first time they hit it.

/*
 *  Your project
 *  β”œβ”€β”€ library-A:1.0  ──depends on──▢  jackson-databind:2.15.3   (depth 2)
 *  └── library-B:1.0  ──depends on──▢  jackson-databind:2.9.8    (depth 2)
 *
 *  Same depth β†’ first declared in YOUR pom.xml's <dependencies> wins.
 *  If library-A is declared before library-B, jackson 2.15.3 wins β€”
 *  NOT because it's newer, but because of declaration order.
 *
 *  Different depths:
 *  Your project
 *  β”œβ”€β”€ jackson-databind:2.15.3                                  (depth 1 β€” DIRECT)
 *  └── library-A:1.0  ──depends on──▢  jackson-databind:2.9.8    (depth 2)
 *
 *  Depth 1 (direct dependency) ALWAYS wins over depth 2+, regardless
 *  of version. This is the most reliable way to force a version:
 *  declare it directly in your own pom.xml.
 */
# See exactly what won and what got overridden ("omitted for conflict")
mvn dependency:tree -Dverbose

# Output excerpt:
# [INFO] +- library-a:1.0:compile
# [INFO] |  \- com.fasterxml.jackson.core:jackson-databind:jar:2.15.3:compile
# [INFO] \- library-b:1.0:compile
# [INFO]    \- (com.fasterxml.jackson.core:jackson-databind:jar:2.9.8:compile - omitted for conflict with 2.15.3)

Three ways to take control β€” in order of preference

// 1. βœ… BEST: declare the version directly β€” depth 1 always wins
<dependency>
    <groupId>com.fasterxml.jackson.core</groupId>
    <artifactId>jackson-databind</artifactId>
    <version>2.15.3</version>
</dependency>

// 2. βœ… BEST AT SCALE: pin it project-wide via dependencyManagement β€”
// no need to also add it as a direct dependency, applies to ALL modules
<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>com.fasterxml.jackson.core</groupId>
            <artifactId>jackson-databind</artifactId>
            <version>2.15.3</version>
        </dependency>
    </dependencies>
</dependencyManagement>

// 3. Last resort: exclude it from the offending parent and declare separately β€”
// more maintenance burden (you must remember to re-declare it)
<dependency>
    <groupId>com.legacy</groupId>
    <artifactId>old-utils</artifactId>
    <exclusions>
        <exclusion>
            <groupId>com.fasterxml.jackson.core</groupId>
            <artifactId>jackson-databind</artifactId>
        </exclusion>
    </exclusions>
</dependency>
A silently-resolved conflict is not a solved conflict

Maven will always pick a version and build successfully β€” it never fails the build just because of a version mismatch. That means a binary-incompatible API change between 2.9.8 and 2.15.3 can compile fine and explode at runtime with NoSuchMethodError, far from wherever the actual conflict originated. Treat any unexpected entry in dependency:tree -Dverbose as something to actively verify, not ignore because the build went green.

Importing a BOM

A Bill of Materials is a pom-packaged artifact containing only dependencyManagement β€” a coordinated set of version pins published by a project so consumers never have to guess which versions work together.

<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>org.springframework</groupId>
            <artifactId>spring-framework-bom</artifactId>
            <version>6.1.0</version>
            <type>pom</type>
            <scope>import</scope>  <!-- merges its dependencyManagement into yours -->
        </dependency>
    </dependencies>
</dependencyManagement>

// Now declare without a version β€” the BOM supplies it
<dependency>
    <groupId>org.springframework</groupId>
    <artifactId>spring-core</artifactId>
</dependency>
spring-boot-starter-parent vs importing the BOM directly

When you inherit from spring-boot-starter-parent, you get the Spring Boot BOM and plugin defaults and resource filtering β€” but a project can only have ONE <parent>. If your project already has its own parent (a company-wide parent POM, for example), you can't also parent from Spring Boot. The fix: import spring-boot-dependencies as a BOM instead of inheriting from spring-boot-starter-parent β€” you get the version management without giving up your own parent.

Practical Exclusion: Swapping an Embedded Server

The classic real-world exclusion isn't fixing a conflict β€” it's swapping out a transitively-included implementation for an alternative.

// Spring Boot's web starter pulls in embedded Tomcat by default β€”
// exclude it to use Jetty or Undertow instead
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
    <exclusions>
        <exclusion>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-tomcat</artifactId>
        </exclusion>
    </exclusions>
</dependency>

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-undertow</artifactId>
</dependency>

Version Ranges β€” and Why You Shouldn't Use Them

// Maven supports mathematical version ranges
<version>[1.0,2.0)</version>   <!-- >=1.0, <2.0 -->
<version>[1.5,)</version>     <!-- >=1.5, any upper bound -->
<version>(,2.0]</version>     <!-- <=2.0 -->
Avoid version ranges in production builds

A range means the exact resolved version can change between builds without any change to your pom.xml β€” the build is no longer reproducible. The same commit can produce different binaries depending purely on when it was built. Pin exact versions (via properties or a BOM) and update deliberately with mvn versions:display-dependency-updates instead of letting the range silently drift.

Diagnostic Commands Reference

# Full dependency tree with conflict resolution shown
mvn dependency:tree -Dverbose

# Filter to one artifact β€” find where it's coming from
mvn dependency:tree -Dincludes=com.fasterxml.jackson.core

# Declared-but-unused, or used-but-undeclared dependencies
# (the latter is a real risk: it compiles because of a transitive dependency
# that could disappear if an unrelated library changes)
mvn dependency:analyze

# Pull down sources/javadoc jars for IDE navigation
mvn dependency:sources
mvn dependency:resolve -Dclassifier=javadoc

# Check for newer available versions across all dependencies
mvn versions:display-dependency-updates

Interview Questions

πŸŽ“ Junior level

Q: What is a transitive dependency?
A dependency of a dependency. If your project depends on library A, and A depends on library B, B is pulled in automatically as a transitive dependency β€” you never declare it yourself.

Q: What does the exclusions element do?
Prevents a specific transitive dependency from being pulled in by a declared dependency. Common uses: removing a conflicting/vulnerable version, or swapping an implementation (excluding Tomcat to use Jetty instead).

πŸ”₯ Senior level

Q: Explain "nearest definition wins" and why it can silently select the wrong version.
Maven resolves version conflicts by depth in the dependency tree β€” the shallowest declaration wins, regardless of which version is actually newer or more correct. A direct dependency (depth 1) always beats a transitive one (depth 2+). At equal depth, declaration order in the POM decides. This means adding an unrelated dependency that happens to declare an old version of a shared library, earlier in your POM, can silently downgrade something you rely on β€” with no warning, since Maven always resolves to some version and builds successfully regardless of binary compatibility.

Q: Why prefer dependencyManagement over a direct exclusion+redeclare for fixing conflicts?
dependencyManagement applies the pin project-wide (or across an entire multi-module reactor if declared in the parent), automatically, for every current and future dependency that transitively pulls in that artifact β€” no exclusions needed anywhere. Exclusion+redeclare only fixes the one dependency you applied it to; if a second, unrelated library later pulls in the same conflicting version, you'd need to repeat the exclusion there too. It doesn't scale and is easy to forget.