Maven Exclusions vs. dependencyManagement: Fixing Transitive Version Conflicts
Maven's nearest-definition rule decides which transitive version wins. Here is when an exclusion is the right fix and when a managed version or BOM is better.
03 Apr 2026, 07:46 UTC

A build that compiles cleanly on a laptop and then throws NoSuchMethodError in a deployed environment is usually not a code bug. It is a classpath bug: two libraries in the dependency graph want different versions of the same artifact, and Maven picked one of them for you. The fix is either an <exclusion> or a managed version in <dependencyManagement> — and choosing the wrong one creates a problem that reappears at the next upgrade.
The short version: use <dependencyManagement> (or an imported BOM) to decide which version wins, and reserve <exclusion> for a transitive branch you genuinely do not want on the classpath at all.
Why Maven picks the version you did not ask for
Maven resolves a conflict with the "nearest definition" rule: among the paths that reach an artifact, the one with the fewest edges from the project root wins. If two paths are the same depth, the dependency declared first in the POM wins. Depth is counted in the dependency graph, not by version number, so the newest release is not automatically selected.
That is why a direct declaration near the top of your POM beats anything a framework drags in, and why adding an unrelated dependency can silently downgrade a library you already had.
Read the tree before you change anything
Run this from the directory containing the POM you are changing (a module directory or the reactor root). It needs no elevated privileges, but it does need Maven installed and either a populated local repository or network access to the configured repositories:
mvn dependency:tree -Dincludes=com.fasterxml.jackson.core:*
-Dincludes filters the output to matching groupId/artifactId patterns, which is far easier to read than the full tree on a large project. Drop the filter when you do not yet know which artifact is duplicated. The -Dverbose flag adds nodes that were omitted because of conflict resolution in many maven-dependency-plugin versions; the exact output format has changed across plugin releases, so confirm what your plugin version prints before relying on it.
Read the tree for two things: which version actually won, and every path that reaches the artifact. If more than one path reaches it, an exclusion on a single dependency will not be enough.
Worked example: two paths to the same JSON library
Suppose reporting-lib brings in jackson-databind 2.9.x, and web-lib needs 2.15.x. If reporting-lib is shallower in the graph, 2.9.x wins and the newer API calls fail at runtime.
Option A — exclude the old branch and declare the version you want directly:
<dependency>
<groupId>com.example</groupId>
<artifactId>reporting-lib</artifactId>
<version>4.1.0</version>
<exclusions>
<exclusion>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.15.2</version>
</dependency>
Option B — pin the version once in <dependencyManagement> and leave the dependency declarations alone:
<dependencyManagement>
<dependencies>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.15.2</version>
</dependency>
</dependencies>
</dependencyManagement>
Option B wins on most projects. A managed version applies to every path that reaches the artifact, so it does not matter whether a third library later introduces another route to jackson-databind. It also lives in one place, which matters in a multi-module reactor where child POMs would otherwise each need their own exclusion. The same mechanism is what a BOM provides: importing a BOM, such as a Spring Boot or Jackson BOM, puts a tested set of versions into <dependencyManagement> for you.
An exclusion, by contrast, is scoped to the dependency it is declared inside. It removes that one branch; it does not remove the artifact if another path still reaches it.
Where exclusions still earn their place, and what they cost
Exclusions are the right tool when a transitive artifact should not be on the classpath at all: a logging binding that conflicts with your chosen backend, a test-only library leaking into runtime scope, or a component you have replaced for a security reason. In those cases a managed version does not help, because you want the artifact gone rather than pinned.
The cost is binary compatibility. If the excluded library was actually needed — including via reflection or a runtime service loader, which static analysis will not see — you get ClassNotFoundException or a missing-provider error at runtime rather than a compile error. Two limitations are worth stating plainly:
mvn dependency:analyzereports used-undeclared and unused-declared dependencies, but it is heuristic. Reflection, annotation processors and runtime-only usage produce false positives, so treat its output as a prompt to investigate, not a verdict.- A BOM only manages the artifacts it lists. Anything outside the BOM still resolves by nearest definition, so an imported BOM is not a blanket guarantee.
Checking the result
- Re-run
mvn dependency:tree -Dincludes=...and confirm the winning version is the one you intended and that the excluded artifact is absent from the paths you meant to cut. - Run
mvn clean verify(or at minimummvn clean test) so tests execute against the resolved classpath rather than a stale build. - If you excluded something, exercise the code path that used the removed library — a test that touches it is the cheapest way to catch a missing class before deployment.
Then annotate each exclusion with a comment naming the reason and the issue or advisory that prompted it. Exclusions outlive the reasoning behind them; without a note, the next person to upgrade the parent library will not know whether it is still needed.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.