Two libraries need different versions of the same dependency. What happens?

Asked constantlyintermediate1–15 yrs13 min readJava 9

One version wins and the other is discarded — there is no classpath on which both exist. Maven picks the one declared nearest the root, Gradle picks the highest, so the same dependency set resolves differently under each. Whoever needed the loser then fails at runtime with a linkage error, on first execution of that call site and not before.

The Answer

Say this in the room. 45 seconds.

  • Only one version ends up on the classpath. There is no arrangement where both exist — same package, same class name, and the loader takes the first match.
  • The build tool chooses, and the tools choose differently. Maven: nearest to the root wins. Gradle: highest version wins. Maven's rule means a newer version can lose to an older one declared closer.
  • Nothing fails at build time. Everything compiled against the version that lost is now linked against a different one.
  • The failure is a linkage errorNoSuchMethodError, NoSuchFieldError, AbstractMethodError, NoClassDefFoundError — and it is thrown by the JVM, not by any library.
  • It fires the first time that call site executes, so a conflict on a rarely-taken path passes CI and surfaces in production.
  • Diagnose with mvn dependency:tree or gradle dependencyInsight. Fix by pinning one version deliberately — a BOM or dependencyManagement — not by adding exclusions until it compiles.

Understand It

One classpath, one winner

Your app depends on two libraries; both drag in a third, at different versions, from different depths. Nobody declared the third one at all.

Compiled and run on this build
// Your app depends on two libraries. Both drag in commons-lang, at different
// versions, from different depths. Nobody declared commons-lang directly.
//
//   your-app
//     +- report-service 2.1        -> commons-lang 3.4      (depth 2)
//     +- audit-client 1.7
//          +- http-toolkit 4.2     -> commons-lang 3.12     (depth 3)
var candidates = List.of(
    new Dep("commons-lang", "3.4",  2, "report-service"),
    new Dep("commons-lang", "3.12", 3, "audit-client -> http-toolkit"));

var maven = nearestWins(candidates);
var gradle = highestWins(candidates);
System.out.println("  Maven  picks " + maven.version() + "  (nearest: depth " + maven.depth() + ", via " + maven.via() + ")");
System.out.println("  Gradle picks " + gradle.version() + " (highest version anyone asked for)");
System.out.println("  same dependencies, two build tools, two different jars on the classpath");
System.out.println();

// And the version people assume wins — the newest — is not what Maven does.
// Note 3.4 vs 3.12: as strings, "3.4" sorts above "3.12".
System.out.println("  is 3.12 newer than 3.4?           " + (compareVersions("3.12", "3.4") > 0));
System.out.println("  does string comparison agree?     " + ("3.12".compareTo("3.4") > 0));
Output
  Maven  picks 3.4  (nearest: depth 2, via report-service)
  Gradle picks 3.12 (highest version anyone asked for)
  same dependencies, two build tools, two different jars on the classpath

  is 3.12 newer than 3.4?           true
  does string comparison agree?     false

Two things people get wrong here.

Maven does not pick the newest. It picks the nearest declaration to the root, and 3.4 is two hops away against 3.12's three. A dependency you never mentioned just downgraded a library, and adding an unrelated library later can change which one is nearest — so the resolved version moves without anyone editing a version number.

Version comparison is not string comparison. "3.12".compareTo("3.4") is negative, because '1' < '4'. Any hand-rolled "is this newer" check that compares strings is wrong at exactly the point versions stop being single digits, which is most real libraries.

The two functions above are a model, not Maven. The rules they implement are the real ones — nearest-wins and highest-wins — but a real resolver also handles scopes, exclusions, dependencyManagement, platform constraints and cycles. Use it to understand why a version was chosen; use dependency:tree to find out which one actually was.

What the loser's callers get

The library that lost is simply gone. Code compiled against it is now linked against a different one. This is real: two versions compiled here at runtime, loaded through separate class loaders.

Compiled and run on this build
// Two real versions of one library, compiled here at runtime, then loaded
// through separate class loaders. Nothing is simulated: the error below is
// thrown by the JVM while it links the call site.
try (var w = new Workspace()) {

    // commons-lang 3.4 — join takes an array.
    w.library("3.4", """
        public class Lib {
            public static String join(String[] parts) { return String.join("", parts); }
        }
        """);

    // 3.12 added an overload taking a separator.
    w.library("3.12", """
        public class Lib {
            public static String join(String[] parts) { return String.join("", parts); }
            public static String join(String[] parts, char sep) { return String.join(String.valueOf(sep), parts); }
        }
        """);

    // report-service was built against 3.12 and uses the new overload.
    w.appCompiledAgainst("3.12", """
        public class App {
            public static String run() { return Lib.join(new String[]{"a","b","c"}, '-'); }
        }
        """);

    System.out.println("  compiled against 3.12, running on 3.12 : " + w.runAgainst("3.12"));
    System.out.println("  compiled against 3.12, running on 3.4  : " + w.runAgainst("3.4"));
}
Output
  compiled against 3.12, running on 3.12 : returned a-b-c
  compiled against 3.12, running on 3.4  : NoSuchMethodError: 'java.lang.String Lib.join(java.lang.String[], char)'

That message is the JVM's, and its shape is the clue: it names a full signature, not just a method name. javac records the exact descriptor of the method it resolved at the call site, and the JVM later demands a method matching that descriptor exactly. An overload that looks compatible to a human is a different method to the JVM.

So NoSuchMethodError almost never means "you called something that does not exist". It means "you compiled against one version and are running against another", which is why it is a dependency problem rather than a code problem, and why re-reading your own source rarely helps.

The whole family, and what each one tells you

The same mismatch produces different errors depending on what changed.

Compiled and run on this build
// Four ways one library upgrade breaks a caller that was compiled against a
// different version. Each pair below is compiled and run for real.

// A method signature changed.
System.out.println("  method gone   : " + linkage(
    "public class Lib { public static String go(String s) { return s; } }",
    "public class Lib { public static String go(String s, int n) { return s; } }",
    "public class App { public static String run() { return Lib.go(\"x\"); } }"));

// A non-constant field was removed.
System.out.println("  field gone    : " + linkage(
    "public class Lib { public static int MAX = 100; }",
    "public class Lib { public static int LIMIT = 100; }",
    "public class App { public static String run() { return \"\" + Lib.MAX; } }"));

// An interface gained a method, and your class does not implement it.
System.out.println("  interface grew: " + linkage(
    """
    public class Lib {}
    interface Handler { String handle(); }
    class Caller { static String call(Handler h) { return h.handle(); } }
    """,
    """
    public class Lib {}
    interface Handler { String handle(); String handle(int n); }
    class Caller { static String call(Handler h) { return h.handle(1); } }
    """,
    """
    public class App implements Handler {
        public String handle() { return "mine"; }
        public static String run() { return Caller.call(new App()); }
    }
    """));

// A class was removed or repackaged.
System.out.println("  class gone    : " + linkage(
    "public class Lib { public static class Helper { public static String v() { return \"h\"; } } }",
    "public class Lib { }",
    "public class App { public static String run() { return Lib.Helper.v(); } }"));
Output
  method gone   : NoSuchMethodError: 'java.lang.String Lib.go(java.lang.String)'
  field gone    : NoSuchFieldError: Class Lib does not have member field 'int MAX'
  interface grew: AbstractMethodError: Receiver class App does not define or inherit an implementation of the resolved method 'abstract java.lang.String handle(int)' of interface Handler.
  class gone    : NoClassDefFoundError: Lib$Helper

Read them as a diagnosis rather than a stack trace:

ErrorWhat changedWhere to look
NoSuchMethodErrora method was removed, renamed, or its signature changedthe named signature — check which version has it
NoSuchFieldErrora non-constant field was removed or renamedsame; note a static final primitive is inlined and will not fail
AbstractMethodErroran interface gained a method your class does not implementyour implementations of that library's interfaces
NoClassDefFoundErrora class was removed, repackaged, or the jar is missingwhether the artifact coordinates changed
IncompatibleClassChangeErrora class became an interface, static became instance, and similara major-version upgrade

The static final note matters more than it sounds. Constants are copied into the calling class at compile time, so a caller can hold a stale value from a version that is no longer anywhere on the classpath — with no error, ever. That is the same defect as a NoSuchFieldError and it fails silently instead.

Why your tests passed

Class loading resolves nothing. The JVM links each call site lazily, the first time it executes.

Compiled and run on this build
// One class, two call sites. Only one of them uses the method that vanished.
try (var w = new Workspace()) {
    w.library("3.12", """
        public class Lib {
            public static String common() { return "ok"; }
            public static String added(char sep) { return "new"; }
        }
        """);
    w.library("3.4", """
        public class Lib {
            public static String common() { return "ok"; }
        }
        """);
    w.appCompiledAgainst("3.12", """
        public class App {
            public static String checkout() { return Lib.common(); }
            public static String refund()   { return Lib.added('-'); }
        }
        """);

    // Loading the class does not resolve its call sites.
    System.out.println("  loading App against 3.4      : " + w.loadOnlyAgainst("3.4"));

    // Neither does running the path that happens not to touch the missing method.
    System.out.println("  calling checkout() on 3.4    : " + w.runAgainst("3.4", "checkout"));

    // The error appears the first time THAT call site executes, and not before.
    System.out.println("  calling refund() on 3.4      : " + w.runAgainst("3.4", "refund"));
}
Output
  loading App against 3.4      : loaded, no error
  calling checkout() on 3.4    : returned ok
  calling refund() on 3.4      : NoSuchMethodError: 'java.lang.String Lib.added(char)'

This is the entire reason dependency conflicts reach production. The application starts. Health checks pass. The common path works. Every class in the jar loads without complaint, because loading a class does not check that the methods it calls exist.

The error waits at one call site, and it fires when a customer requests a refund, or when the month-end report runs, or when a retry takes the branch nobody exercises. A build that "works" tells you nothing about call sites you did not execute — which is also why the honest answer to "how do you know you have no conflicts?" is a resolution check in the build, not a green test suite.


Reference

The correct implementation, the configuration, and the migration path. Copy from here.

Finding out what actually resolved

# Maven — the whole tree, then just the conflicting artifact.
mvn dependency:tree
mvn dependency:tree -Dincludes=org.apache.commons:commons-lang3
mvn dependency:tree -Dverbose            # shows the versions that were REJECTED and why

# What is the final answer, ignoring how it got there?
mvn dependency:list | sort

# Gradle — why is this version here?
./gradlew dependencies --configuration runtimeClasspath
./gradlew dependencyInsight --dependency commons-lang3 --configuration runtimeClasspath

-Dverbose is the flag worth remembering: the plain tree shows the winner, and the verbose tree shows every version that was considered and the phrase omitted for conflict with 3.4, which is the line that names your problem.

Pinning a version on purpose

<!-- Maven: dependencyManagement wins over every transitive version, at any depth. -->
<dependencyManagement>
  <dependencies>
    <dependency>
      <groupId>org.apache.commons</groupId>
      <artifactId>commons-lang3</artifactId>
      <version>3.12.0</version>
    </dependency>
  </dependencies>
</dependencyManagement>
<!-- Better: import a BOM, so a whole family of artifacts stays consistent.
     Mixing jackson-core 2.15 with jackson-databind 2.13 is its own bug. -->
<dependencyManagement>
  <dependencies>
    <dependency>
      <groupId>com.fasterxml.jackson</groupId>
      <artifactId>jackson-bom</artifactId>
      <version>2.17.1</version>
      <type>pom</type>
      <scope>import</scope>
    </dependency>
  </dependencies>
</dependencyManagement>
// Gradle: a constraint states intent and survives an upgrade elsewhere.
dependencies {
    constraints {
        implementation("org.apache.commons:commons-lang3:3.12.0") {
            because("audit-client needs 3.12+, report-service pins 3.4")
        }
    }
    implementation(platform("com.fasterxml.jackson:jackson-bom:2.17.1"))   // BOM equivalent
}

// The blunt instrument. Fails the build on a downgrade rather than resolving silently.
configurations.all {
    resolutionStrategy {
        failOnVersionConflict()
        force("org.apache.commons:commons-lang3:3.12.0")
    }
}

Pin in dependencyManagement or a constraint, not by adding a direct dependency. A direct dependency pins the version and claims you use the library, so nobody can tell later whether it can be removed. The because clause is the part people skip and the part that saves the next person a day.

Failing the build instead of finding out in production

<!-- Maven Enforcer: refuse to build when two versions of anything are in play. -->
<plugin>
  <groupId>org.apache.maven.plugins</groupId>
  <artifactId>maven-enforcer-plugin</artifactId>
  <executions>
    <execution>
      <id>enforce-versions</id>
      <goals><goal>enforce</goal></goals>
      <configuration>
        <rules>
          <dependencyConvergence/>          <!-- all paths must agree on a version -->
          <banDuplicateClasses/>            <!-- from the extra-enforcer-rules dependency -->
        </rules>
      </configuration>
    </execution>
  </executions>
</plugin>

dependencyConvergence is strict enough to be annoying on a large existing project and is worth turning on anyway, in a new module first. It converts "a linkage error next quarter" into "a build failure today, naming both paths".

When the versions are genuinely incompatible

Sometimes two libraries need versions that cannot be reconciled. Shading relocates one copy into its own package so both can coexist:

<plugin>
  <artifactId>maven-shade-plugin</artifactId>
  <configuration>
    <relocations>
      <relocation>
        <pattern>org.apache.commons.lang3</pattern>
        <shadedPattern>com.example.shaded.commons.lang3</shadedPattern>
      </relocation>
    </relocations>
  </configuration>
</plugin>

This is a real fix and a real cost. Stack traces now name com.example.shaded..., the shaded copy never receives security updates through normal dependency management, and any class loaded reflectively by name will not be found. Reach for it when you publish a library and cannot control your consumers' classpath — rarely in an application, where upgrading one of the two is almost always the better answer.

What not to do

<!-- Exclusions until it compiles. Each one removes a jar someone needed. -->
<dependency>
  <groupId>com.example</groupId>
  <artifactId>audit-client</artifactId>
  <exclusions>
    <exclusion><groupId>*</groupId><artifactId>*</artifactId></exclusion>
  </exclusions>
</dependency>

An exclusion says "this path does not need that dependency", which is a claim about someone else's code. It is right when a library pulls in something genuinely optional — a logging backend it should not have chosen for you. It is wrong as a way to silence a version conflict, because the removed jar was needed, and the failure moves from a resolvable conflict to a NoClassDefFoundError with no version to blame.


Scenarios

Real situations, with the decision and the argument.

1. NoSuchMethodError in production for a method that is right there in the source.

The source you are reading is not the source that is running. Something compiled against a different version of that class, and the JVM is refusing to link the call site.

Get the resolved version first — mvn dependency:tree -Dverbose -Dincludes=<artifact> names the winner and the versions omitted for conflict. Then check which of them actually has the signature in the error message, because the message gives you the full descriptor and that is enough to identify the version. The fix is to pin whichever version has everything all callers need, and if no such version exists, that is the real finding and it is a different conversation.

2. The build works locally and fails on CI, or the other way round.

Two possibilities and they are worth separating before anyone starts guessing. Either the resolved versions genuinely differ — a stale local repository, a snapshot, a version range, a different profile — or they are identical and something else differs, usually the JDK.

mvn dependency:list | sort on both, and diff the output. If the lists match, stop looking at dependencies. If they do not, the first difference is your answer, and a lock file (gradle.lockfile, or Maven's dependency:go-offline against a fixed repository) stops it recurring.

3. A conflict appeared after adding a library that has nothing to do with the failure.

This is Maven's nearest-wins rule doing exactly what it says. The new library introduced a shorter path to a shared dependency, so the version resolved at a different depth and something unrelated changed underneath you.

It is worth saying plainly in review: under nearest-wins, adding any dependency can silently change the version of any other. That is not a bug in the new library, and it is the argument for pinning shared infrastructure — Jackson, Guava, commons, the logging stack — in dependencyManagement at the root, where depth cannot move it.

4. A CVE scanner flags a transitive dependency three levels down.

Try the cheap fix first: pin the patched version in dependencyManagement. Transitive dependencies are usually loosely coupled to their parent, so this often just works, and it works today rather than after an upstream release.

Then verify what you actually did. Pinning changes the version for every path, so run the tests that exercise the intermediate library rather than assuming a patch release is safe. If the patched version has a breaking change the intermediate library cannot take, you are choosing between upgrading that library, forking it, and accepting the risk with a documented expiry date — and that is a decision to escalate, not to make silently in a pom.

5. Someone proposes shading to end the argument.

Ask whether this is a library or an application. In an application you control the whole classpath, so upgrading one of the two conflicting consumers is nearly always cheaper than carrying a shaded copy forever.

In a published library the calculation flips: you cannot control your consumers' classpath, and shading a dependency you use internally spares every one of them a conflict with your choices. Even then, be specific about the cost — the shaded copy is invisible to dependency scanners and security updates, so it needs an owner and a review date, not just a plugin block.


Interviewer's Next Move

The question after the question. This is what the round is actually testing.

1. "Two libraries need different versions of the same dependency. What happens?" One wins and the other is discarded — there is no classpath holding both. The build tool decides: Maven takes the declaration nearest the root, Gradle takes the highest version. Whatever was compiled against the loser is now linked against something else.

2. "So which version does Maven pick?" The nearest to the root, not the newest. A 3.4 at depth two beats a 3.12 at depth three, so a transitive dependency can silently downgrade a library — and adding an unrelated dependency later can change which one is nearest.

3. "What does the failure look like?" A linkage error thrown by the JVM: NoSuchMethodError, NoSuchFieldError, AbstractMethodError, NoClassDefFoundError. Not an exception from the library — the JVM refusing to link a call site whose exact signature is not present.

4. "Why does NoSuchMethodError name a full signature?" Because javac records the resolved descriptor at the call site and the JVM demands an exact match later. That is also why the message identifies which version you compiled against, and why the error means "version mismatch" rather than "typo".

5. "Why didn't the tests catch it?" Linking is lazy and per call site. The class loads, the common paths run, and the error waits at the one call site nobody executed. A green build says nothing about code paths that were not taken.

6. "How do you find out where a dependency came from?" mvn dependency:tree -Dverbose -Dincludes=<artifact>, which shows the rejected versions and the phrase omitted for conflict. Gradle's equivalent is dependencyInsight --dependency <name>.

7. "How do you fix it?" Pin one version deliberately, in dependencyManagement or a Gradle constraint, choosing the one that satisfies every caller. Prefer a BOM for anything that ships as a family. Add dependencyConvergence in the enforcer so the next one fails the build instead of production.

8. "When would you use an exclusion instead?" When a path genuinely does not need the dependency — a library that picked a logging backend for you. Not to silence a version conflict, because the jar was needed and you have converted a solvable version problem into a missing class.

9. "What about shading?" It relocates a copy into your own package so two incompatible versions can coexist. Appropriate when publishing a library and unable to control consumers' classpaths. In an application it is usually the wrong trade: the shaded copy is invisible to security scanning, stack traces get harder to read, and reflection by class name breaks.

10. "Does JPMS change this?" On the module path, yes — two modules with the same name is a startup error and split packages are rejected, so the conflict surfaces at launch rather than at a call site. Most applications still run on the classpath, where the old rules apply.

Code traps

Trap A — predict before you run:

// Library, version 1.0
public class Config {
    public static final int TIMEOUT = 30;
}

// Version 2.0 changes it
public class Config {
    public static final int TIMEOUT = 60;
}
Answer

A caller compiled against 1.0 keeps using 30, even when only 2.0 is on the classpath, and nothing anywhere reports a problem.

static final primitives and string literals are compile-time constants: javac copies the value into the calling class's constant pool, so there is no field read at runtime and nothing to link. The upgrade appears to have been picked up — the jar is right there — and the behaviour did not change.

This is the silent version of NoSuchFieldError, and it is why a full clean rebuild of everything is part of an upgrade rather than an optional step. Making the field non-final, or exposing it through a method, removes the inlining.

Trap B:

if (libraryVersion.compareTo("3.9") >= 0) {
    useNewApi();
}
Answer

"3.10".compareTo("3.9") is negative, because '1' sorts before '9'. So the check fails on 3.10, 3.11 and every version after — precisely the ones it was meant to include.

Version strings are not lexicographically ordered. Compare numeric components, or use the runtime version object your build system exposes. The same bug appears in scripts that sort tags to find "the latest release" and quietly pick 1.9 over 1.10.

Trap C:

<dependency>
  <groupId>com.example</groupId>
  <artifactId>audit-client</artifactId>
  <version>1.7</version>
  <exclusions>
    <exclusion>
      <groupId>org.apache.commons</groupId>
      <artifactId>commons-lang3</artifactId>
    </exclusion>
  </exclusions>
</dependency>
Answer

This ends the version conflict by removing the dependency, and audit-client still needs it. If another path happens to supply commons-lang3, the build works by accident and breaks the day that path is removed. If nothing supplies it, you get NoClassDefFoundError — a worse failure than the one you started with, because there is no version to compare.

The exclusion also hides the conflict from dependency:tree, so the next person cannot see what was reconciled. Pinning the version in dependencyManagement fixes it visibly and keeps the jar.

Common wrong answers

Said in interviewsReality
"Both versions load, isolated."Not on one classpath. One wins; the other is gone.
"Maven picks the newest."Nearest to the root, which can be older.
"Maven and Gradle resolve the same."Nearest-wins versus highest-wins — different answers.
"It fails at build time."It compiles cleanly. The JVM finds out later.
"NoSuchMethodError means the method doesn't exist."It means you compiled against a different version.
"The tests would catch it."Linking is lazy and per call site.
"Add exclusions until it builds."Converts a version conflict into a missing class.
"Upgrading is always safe within a major version."Only if everyone honours semver, and a removed method is a removed method.
"static final upgrades pick up the new value."Constants are inlined at compile time into every caller.

Check Yourself

Q1. Maven resolved commons-lang 3.4 when a transitive path asked for 3.12. Is that a bug?

AnswerNo — it is nearest-wins working as designed. Maven picks the declaration closest to the root of the tree, so a 3.4 at depth two beats a 3.12 at depth three regardless of which is newer. The consequence is that adding any unrelated dependency can change which path is nearest and silently move the resolved version, which is the argument for pinning shared libraries in dependencyManagement where depth cannot reach them.

Q2. Your service starts, passes health checks, serves traffic for a week, then throws NoSuchMethodError. How is that possible?

AnswerThe JVM links each call site lazily, the first time it executes. Loading a class does not verify that the methods it calls exist, so the application starts and every common path works. The error waits at the one call site that had not run yet — a refund, a month-end job, a retry branch. It follows that a green test suite is evidence about the paths you executed and nothing else.

Q3. You upgrade a library from 1.0 to 2.0 where a public static final int changed value, rebuild only the module you edited, and the old value is still in effect. Why, and what is the general lesson?

AnswerCompile-time constants are inlined: javac copies the value into every calling class's constant pool, so there is no runtime field read and nothing to relink. Modules you did not recompile keep the old value with no error of any kind. The lesson is that a partial rebuild is not a valid way to test a dependency upgrade — and that this failure mode is silent, unlike its NoSuchFieldError sibling, which is what makes it worse.


Practice

TierExerciseTime
Warm-upProduce four linkage errors10 min
ChallengeResolve six conflicting trees25 min
ProductionThe refund path that failed once a week45 min
InterviewFull round replay10 min

What changed, and when

Version boundaries are where follow-ups live. Know the change and the behaviour it replaced.

  1. Java 9

    On the module path, two modules with the same name is an error the JVM refuses to start with, and split packages are rejected outright. The conflict surfaces at launch instead of at a call site.

    Before Java 9: The classpath silently accepted duplicate classes and used whichever appeared first, so the same jar order that worked locally could differ in production.

Practice ladder

Reading this page is not knowing it. Four tiers, ending in a real incident.

Where this question goes next

Every runnable example above was compiled and executed against openjdk 21.0.11 on this build, and its output diffed against what this page claims. Last updated 2026-08-30.