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>
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>
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 -->
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
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).
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.