skip to content

Library A and library B both live in the same monorepo and both depend on library C, but A was written against C's old API and B was written against C's newer API. What is this shape of conflict usually called, and why does building a project that depends on both A and B expose it, even though A and B individually build fine on their own?

level: middleimportance: should knowfreq 55%

answer

  1. diamond shape in dependency graph
  2. A and B individually fine, D exposes it
  3. single version resolution forces a choice
  4. NoSuchMethodError classic JVM symptom

basics

~20 s

It's called a diamond dependency conflict — A and B each want a different version/shape of C, and that's invisible until something needs both A and B at once, which forces C to be one single thing.

solid answer

~50 s

This is the classic "diamond dependency" shape: two nodes (A, B) that both depend on a shared third node (C), forming a diamond in the dependency graph when a top-level project depends on both A and B. If A was built against an old version/API of C and B against a newer one, each of A and B individually builds fine because each only ever sees the version of C it expects. The conflict only becomes visible at the point where both paths converge — a consumer depending on both A and B — because most build systems (especially in a single-version-policy monorepo) can only resolve one concrete version of C into that build, and whichever version wins, the other side's assumptions about C's API are violated. It's a structural consequence of transitive dependencies, not a bug in A or B individually.

go deeper

for a junior

Should recognize, given the scenario, that the problem only appears because something needs both A and B together, not because A or B is individually broken.

for a middle

Should be able to describe the graph shape and explain mechanically why only one concrete version of the shared dependency can be resolved into a single build/runtime.

for a senior

Should know at least one concrete resolution strategy (nearest-wins, explicit pin/override, coordinated upgrade) and be able to compare a hard build failure against a silent duplicate-copy outcome in terms of risk.

for a principal

Should connect this to why single-version-policy monorepos treat it as a build-graph-level concern rather than a per-project testing concern, and should be able to discuss real resolution tooling (version catalogs, dependencyManagement, Bazel's single-node-per-dependency model) as the systemic fix versus ad hoc vendoring.

## Where the name comes from The 'diamond' name comes directly from the shape you'd draw on paper: a top node (the consumer, call it project D) with two arrows going down to A and B, and both A and B each having an arrow going down to the same third node, C. Draw it out and the four nodes and four edges form a diamond. This shape is unavoidable in any nontrivial dependency graph — any two widely-used libraries are likely to share some common lower-level dependency (a logging library, a date/time utility, an HTTP client) — so the diamond shape itself is not the problem. The problem is what happens when the two paths down to C disagree about which C they need. ## The conflict, concretely Concretely: - **Library A** was written and tested against C at, say, major version 1, using an API where a function `parse(string)` returns a plain object. - **Library B** was written later, against C major version 2, where the same team changed `parse` to return a wrapped `Result` type as part of a breaking API redesign. - **A, in isolation**, builds and passes its tests fine — it only ever imports C 1.x, and 1.x is exactly what it expects. - **B, in isolation**, also builds and passes fine — it only ever imports C 2.x. Neither library's own CI run has any way to detect a problem, because neither one ever sees the other version of C. The conflict is **latent**, sitting in the graph, invisible until someone tries to build project D, which depends on both A and B. Now the build system has to answer: which single version of C does D's process actually load? Most runtime and build environments (a single Python interpreter's module cache, a single JVM classpath, a single Node `node_modules` resolution when hoisting is used, and virtually all single-version-policy monorepo build systems by design) can only hold one concrete implementation of C in that build. Whichever version 'wins' the resolution, the other library's assumptions break — if C resolves to 2.x, A's `parse(string)` call now returns a `Result` object where A's code expects a plain object, and A either throws at runtime or silently misbehaves depending on how defensively it was written. ## Why it earns its own name Why does this happen specifically, and why is it worth naming as its own failure mode rather than just 'library incompatibility'? Because the two sides of the diamond can each be internally correct and individually well-tested, and the bug only exists in the **combination** — it's a property of the graph, not of either component. This is exactly why per-project CI in a monorepo is not sufficient on its own to catch it: A's test suite passes, B's test suite passes, and the failure only appears in D's build or, worse, only at runtime in D if the build system silently picks a version rather than failing loudly (some ecosystems, notably some JavaScript package managers with nested `node_modules`, will actually let both versions of C exist as separate, non-communicating copies rather than erroring — which avoids the build failure but creates its own subtler bugs, like two disconnected copies of C's internal singleton state). ## How single-source-of-truth surfaces it The single-source-of-truth version policy discussed elsewhere in this topic area is precisely the mechanism that surfaces this conflict early and mechanically instead of at random downstream build time: if the whole monorepo enforces exactly one version of C, then the moment B is added (or upgraded) to depend on C 2.x while A still depends on C 1.x, the build system flags an unresolvable conflict immediately, at the point the incompatible dependency was introduced, rather than waiting for someone to build D. The fix is always some form of getting A and B to agree: - **upgrade A** to work against C 2.x (possibly requiring code changes in A, the classic 'coordinated migration'), - **pin B back** to C 1.x if 2.x isn't actually needed yet, - or, as a last resort in some ecosystems, physically **vendor/rename one copy of C** so the two versions can coexist without symbol collision (a workaround, not a real fix, and one that reintroduces the duplicate-copy risk). ## The classic JVM instance A well-known real-world instance of this pattern is the **JVM classpath diamond problem** — two libraries both depending on different major versions of a common library like Guava or Jackson, producing a `NoSuchMethodError` or `ClassNotFoundException` at runtime rather than at compile or resolution time, precisely because the JVM classloader picks one version of a class silently rather than erroring at build time. Build tools like Maven and Gradle have entire dependency-resolution strategies (nearest-wins, explicit `dependencyManagement/version-catalog` pins, strictly version constraints) built specifically to make this failure surface as a build-time error instead of a runtime surprise.

  • If a build tool silently picks one version of the shared library C instead of failing the build, what's the practical risk compared to a hard build failure?
    A hard failure is annoying but safe — you find out immediately and can't ship broken code. A silent pick means the build succeeds but one of A or B is now running against an API it wasn't tested against, so the bug surfaces later as a runtime exception or, worse, as silently wrong behavior if the API shift is subtle (e.g., different rounding, different default timeout) rather than a hard type mismatch.
  • Why doesn't running A's and B's own test suites catch this conflict before it reaches project D?
    Because A's tests only ever exercise A linked against the version of C that A itself depends on, and likewise for B — the conflict only exists in the combined graph that D creates by depending on both, so no test that runs A or B in isolation ever constructs the problematic state. This is why dependency-conflict detection has to happen at resolution/build-graph time for the combined project, not purely at the unit-test level.
  • What's the difference between fixing a diamond dependency conflict and just vendoring/renaming one copy of the shared library so both versions coexist?
    Fixing it means getting A and B to actually agree on one version of C, which is more work but keeps a single, tested implementation in the build. Vendoring/renaming lets both versions physically coexist without a symbol collision, which unblocks the build but reintroduces the duplicate-copy costs (larger binary, two sets of bugs to patch, possible desynchronized state) that single-version policies exist to avoid — it's a pressure-relief valve, not a resolution.

Like two contractors each promising a client they can deliver windows that fit a particular frame size — each contractor's windows fit fine on their own job sites, but the moment you hire both to work on the same house and they were each sized for a different frame standard, only one frame size can actually go into the wall.

saying these in an interview costs you the question

  • thinks the conflict would show up in A's or B's own test suite
  • can't explain why the conflict is invisible until a common consumer exists
  • assumes build tools always fail loudly on version disagreement
  • conflates a diamond dependency with a circular dependency

context