A common rule of thumb is to depend in the direction of stability. What actually makes one dependency target more stable than another, and how much of that stability is a property of your ecosystem's packaging rather than of the code you depend on?
answer
- I = Ce/(Ca+Ce); stable should be abstract
- Volatility = rate of change x blast radius
- npm nests: two majors, duplicate-instance bugs
- Flat classpath: one version, NoSuchMethodError
- Go: major version in the import path
basics
~20 sStability is how costly change is for the target: many incoming dependents and few outgoing ones make it hard to change, which is why stable things should be abstract. But packaging decides blast radius: npm and Cargo can hold two major versions in one process, a Java classpath cannot, so the identical edge is far more volatile on the JVM.
solid answer
~50 sTwo independent factors: how often the target changes, and how far a change can travel. Instability is roughly outgoing over total edges - a target with many dependents and few dependencies is expensive to change, so it should be abstract enough to extend without changing. Blast radius is ecosystem-specific: - **npm** nests `node_modules`, so two majors of one library coexist; the failure mode moves to duplicate instances - two copies of a library mean two module caches, and identity checks across the copies fail. - **Maven/Gradle** enforce one class per name on a flat classpath; nearest-wins mediation forces the entire graph to agree, and the escape hatch is relocation (shading). - **Go modules** put the major version in the import path (`.../v2`), so majors are simply different packages that coexist by construction. - **Cargo** allows semver-incompatible versions side by side, but their types do not unify - hence "expected Trait, found Trait". - **.NET** loads one assembly per identity per load context, with `AssemblyLoadContext` as isolation.
code
text · 15 linesgraph: app -> A -> parser 2.x
app -> B -> parser 1.x
npm : node_modules/parser@1 and node_modules/A/node_modules/parser@2
both load; two module caches; an object from one copy fails an
identity/instanceof check against the other
maven : nearest-wins picks ONE parser; B runs against a version it was
never compiled with -> NoSuchMethodError at runtime
go mod : parser v2 lives at .../parser/v2, so the two are different
packages and coexist by construction
cargo : both versions build in; their types do not unify, producing
'expected Trait, found Trait' style errorsgo deeper
Know the basic rule: depend on things that change less often than you do, and prefer an interface over a concrete class when the implementation is likely to be replaced.
Explain incoming versus outgoing dependencies and why a heavily-depended-upon unit should be abstract, with a concrete example of a breaking transitive upgrade.
Add the packaging dimension - single-version classpaths versus nested or path-versioned resolution - and use it to argue where an owned adapter pays for itself.
Frame it as risk management across the whole graph: rate of change times blast radius, ecosystem constraints, compatibility promises, and a deliberate decision about which boundaries the organisation is prepared to defend.
## What stability means here Stability in this sense has nothing to do with correctness or uptime. A unit is stable when it is **hard to change**, and it is hard to change when many things depend on it and it depends on little. The usual formalisation counts edges: with `Ca` incoming dependents and `Ce` outgoing dependencies, instability `I = Ce / (Ca + Ce)` runs from 0 (maximally stable: everyone depends on it, it depends on nobody) to 1 (maximally unstable: depends on everything, nobody depends on it). The guidance follows immediately: point your edges towards lower `I`, and because a stable unit cannot easily change, it should be **abstract** - extendable without modification. A stable concrete unit is the worst quadrant, and a volatile abstract one is pointless. That gives you a good first pass. It also hides the more interesting half of the question. ## Volatility is rate of change times blast radius The metric measures your own graph. What it cannot see is what happens when the target *does* change: how far the change is allowed to travel is decided by your packaging system, and ecosystems answer that very differently for the *same* dependency graph. Consider one shape: your app depends on library A which needs parser 2.x, and on library B which needs parser 1.x. - **npm/Node** resolves by nesting. A gets `parser@2` under its own `node_modules`, the top level keeps `parser@1`, and both load. Nobody is forced to upgrade, so an incompatible release of a transitive dependency is a local event. The cost is the duplicate-instance class of bug: two copies means two module-level caches, two singletons, two sets of constructors, so an identity check on an object made by one copy against the other fails - the familiar "two copies of the framework" symptom. - **Maven/Gradle on the JVM** cannot do this: one fully-qualified class name resolves to one class on the classpath, and the resolver mediates to a single version (nearest-wins in Maven, highest by default in Gradle). So B ends up running against a parser it was never compiled against, and you find out at runtime with a `NoSuchMethodError`. Every transitive library is therefore a *global* commitment shared by the whole graph, which makes the identical dependency edge substantially more volatile than it is under npm. Shading - relocating a library's packages into your own namespace - is the workaround, and it exists precisely because the platform cannot hold two versions. - **Go modules** made this a language-level decision: from v2 onward the major version is part of the import path (`example.com/parser/v2`). Two majors are literally different packages and coexist without ceremony, and the Go 1 compatibility promise makes the standard library about as stable a target as exists. The cost is visible churn in import paths at every major bump. - **Cargo** permits multiple semver-incompatible versions of a crate in one binary, but types from different versions are distinct types. The result is the confusing error where a trait apparently does not match itself, because two versions of the same crate are in the graph - a diagnosis problem rather than a runtime failure. - **.NET** loads one assembly per identity per load context, historically with binding redirects and now with `AssemblyLoadContext` for genuine isolation, which is how plugin hosts run components with conflicting dependencies in one process. So "how volatile is this dependency" is not answerable from the library alone. The same package, with the same release cadence, is a much heavier commitment on a flat classpath than in a nesting resolver. ## Making a target stable on purpose Beyond picking well, you can *manufacture* stability at the edge you own. - **Depend on the narrowest shape.** A consumer-declared interface with two methods changes far less often than a vendor's class with forty. In structurally typed languages this costs nothing; in nominal ones it costs an adapter. - **Own the adapter.** One module in your codebase touches the volatile library; everything else touches your abstraction. Now the blast radius of a breaking upgrade is one file, whatever the packaging system does. - **Prefer targets with an explicit compatibility contract.** A standard library with a written promise, or a crate with a disciplined semver policy, is stable in a way a fast-moving utility library never is regardless of edge counts. - **Watch the direction of abstraction.** Depending on a stable *concrete* type is the trap: it cannot change without breaking everyone, so it does not change, so it accretes special cases instead. That is how shared "common" or "contracts" modules become the least pleasant code in the repository. ## The judgment Count edges to find your unstable units, then ask the packaging question about each external target: if this ships a breaking change tomorrow, how many of my units must move at once? On npm or with Go's versioned import paths, often one. On a flat classpath, potentially all of them. That asymmetry, not the metric, is usually what decides whether an edge needs an adapter in front of it.
- If npm can hold two versions, is the JVM's single-version rule simply worse?No, it is a different trade. One version per name gives a single, coherent object graph: one set of classes, one static registry, no chance of two incompatible instances of the same abstraction meeting at a boundary. Nesting avoids forced upgrades but multiplies caches, singletons and type identities, which produces bugs that are far harder to diagnose than a NoSuchMethodError. The JVM's rule is stricter and noisier; nesting is permissive and quieter until it is not.
- How do you decide which external dependencies deserve an adapter in front of them?Weigh how likely the target is to change against how many of your units would have to move together if it did. A widely-used, slow-moving target with a compatibility promise usually does not need one - wrapping the standard library is waste. A fast-moving library reached from many places, especially on a platform that cannot hold two versions, is the strong case: one owned adapter converts a graph-wide upgrade into a single-file edit.
saying these in an interview costs you the question
- Treating the instability ratio as a score to optimise rather than a way to spot units in the wrong quadrant.
- Assuming a dependency's volatility is a property of the library alone, ignoring whether the platform can hold two versions at once.
- Wrapping every third-party call in an adapter, including stable standard-library APIs, and calling the resulting indirection architecture.
- Believing shading or nested resolution is free - duplicated copies bring duplicated static state, duplicated caches and failing cross-copy identity checks.
- Making a widely-depended-upon module both stable and concrete, then absorbing every caller's special case into it because it can no longer be changed safely.