Diagnosing Maven Dependency Version Conflicts: When Nearest-Wins Silently Picks the Wrong Jar
Maven picks dependency versions by nearest-wins, not highest-wins — so your declared upgrade can silently lose. Diagnose with dependency:tree and fix with pins, exclusions, or Enforcer.
19 Dec 2025, 09:27 UTC

The condition: it builds, then breaks at runtime
Your Maven build succeeds cleanly, but the application fails at runtime with NoSuchMethodError, NoClassDefFoundError, or ClassNotFoundException. Or it runs, but behaves as if an older library version is on the classpath even though your POM clearly declares a newer one. In almost every case like this, the problem is not your code or your declared dependencies — it is Maven's conflict mediation quietly selecting a different version of a transitive dependency than the one you expected.
The root cause is a rule that surprises most developers the first time: Maven resolves version conflicts by nearest-wins, not highest-wins. When two paths in the dependency graph require different versions of the same artifact, the version closest to your project (shallowest in the tree) wins, regardless of which version number is higher. A newer version you declared can silently lose to an older version pulled in one level shallower by another library.
Cause and diagnostic summary
| Symptom | Likely cause | First check |
|---|---|---|
NoSuchMethodError on a method that exists in the declared version | An older transitive version won mediation | dependency:tree "omitted for conflict" lines |
ClassNotFoundException after a dependency upgrade | An <exclusion> removed a class that is now needed | Search POMs for exclusions on that artifact |
| Declared upgrade has no behavioral effect | A shallower transitive path overrides your version | Compare winning version in tree vs. POM |
| Conflicts keep recurring across modules | No central version enforcement | Enforcer dependencyConvergence rule |
Step 1: See what Maven actually resolved
Run the dependency tree for the failing module from the project root (or the module directory). No special permissions are needed; this is a read-only report:
mvn dependency:treeLook for lines containing omitted for conflict with. These tell you exactly which version Maven discarded and which dependency path supplied the winning version. For example, output shaped like this means version 2.17.1 lost to 2.8.2 coming in through some-library:
[INFO] +- com.example:some-library:jar:1.4:compile
[INFO] | \- org.apache.logging.log4j:log4j-core:jar:2.8.2:compile
[INFO] \- org.apache.logging.log4j:log4j-core:jar:2.17.1:compile (omitted for conflict with 2.8.2)Exact wording varies slightly across maven-dependency-plugin versions, but the "omitted for conflict" marker is stable in Maven 3.x. For large graphs, filter to the artifact you care about instead of reading the whole tree:
mvn dependency:tree -Dincludes=org.apache.logging.log4j:log4j-coreVerification: confirm the version your application actually loads at runtime matches the version shown as winning in the tree (for example, by checking the jar in the packaged artifact or the classpath of the running process). If they match, you have confirmed a mediation problem, not a packaging problem.
Step 2: Pick the fix that matches the finding
Fix A — Declare the version directly (fastest, local)
Direct dependencies sit at depth 1, so they always win mediation. Add the artifact to your own <dependencies> with the version you want. This works well for a single-module fix, but it adds a real dependency edge you may not semantically need, and it does not scale across many modules.
Fix B — Pin the version with dependencyManagement (preferred for multi-module builds)
A <dependencyManagement> entry forces the specified version for every transitive occurrence of that artifact, without adding anything to the classpath itself:
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.apache.logging.log4j</groupId>
<artifactId>log4j-core</artifactId>
<version>2.17.1</version>
</dependency>
</dependencies>
</dependencyManagement>Important limitation: dependencyManagement only constrains artifacts that actually appear in the graph. It never introduces a dependency on its own. Place it in the parent POM so all modules inherit the pin.
Fix C — Exclude the unwanted path
When the problem is that a path should not exist at all — a classic case is a duplicate or incompatible logging implementation — exclude it at the point where it enters:
<dependency>
<groupId>com.example</groupId>
<artifactId>some-library</artifactId>
<version>1.4</version>
<exclusions>
<exclusion>
<groupId>org.apache.logging.log4j</groupId>
<artifactId>log4j-core</artifactId>
</exclusion>
</exclusions>
</dependency>Risk to note: exclusions are permanent on that path. If a future upgrade of some-library genuinely needs the excluded artifact, the failure returns as a missing class at runtime — with no build-time warning. Re-check exclusions whenever you upgrade the excluding dependency.
Step 3: Verify the fix
Re-run mvn dependency:tree -Dincludes=... and confirm that no "omitted for conflict" entries remain for the artifact and that the winning version is the one you intended. Then reproduce the original runtime failure scenario. Both checks matter: a clean tree with a still-failing application means the conflict was a symptom of something else, such as a shaded jar or a container-provided classpath entry.
Escalation: enforce convergence when conflicts keep coming back
If you are fixing the same class of conflict repeatedly across modules or teams, manual pinning stops scaling. Escalate to the Maven Enforcer plugin's dependencyConvergence rule, which fails the build whenever transitive paths disagree on a version:
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-enforcer-plugin</artifactId>
<executions>
<execution>
<id>enforce-convergence</id>
<goals><goal>enforce</goal></goals>
<configuration>
<rules>
<dependencyConvergence/>
</rules>
</configuration>
</execution>
</executions>
</plugin>Before enabling it for everyone, add it to a scratch profile and verify the build fails when you deliberately introduce a conflicting transitive dependency. This turns silent runtime surprises into loud, build-time errors — which is the real goal, because a conflict you can see in dependency:tree is a conflict you can fix in minutes instead of debugging for hours in production logs.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.