EvolvabilityGENERALSIMULATEDCONTESTED

Stability and Dependency Direction

Something many modules depend on is expensive to change. Point dependencies toward the things that change least — and treat every stability metric as a hint, never a measurement.

The requirement, the obvious build, and why it breaks

Every lesson starts where the work starts: someone asked for something, and the first implementation that comes to mind survives until the requirement changes.

The question

This module is depended on by twelve others. What does that oblige me to do differently?

The requirement

A shared PricingRules module is imported by twelve modules. Every change to it triggers a full regression run and a coordination email, and it changes almost every sprint. Meanwhile the module it depends on for currency conversion is edited roughly once a year.

The obvious build

Volatility is a property of code you cannot control. Pricing changes often because the business changes often, so there is nothing to design here — you just accept the regression runs.

Why it breaks

It conflates two different things. The pricing *rules* change often; the *shape* of the interface — "give me a price for this basket" — has not changed in two years. Twelve modules depend on the shape, and only pricing itself depends on the rules.

How it breaks as requirements change
  • It conflates two different things. The pricing *rules* change often; the *shape* of the interface — "give me a price for this basket" — has not changed in two years. Twelve modules depend on the shape, and only pricing itself depends on the rules.
  • Once that distinction is visible, the fix is available: keep the volatile part behind a stable interface, and the twelve dependents stop noticing the weekly changes entirely (Encapsulation Radius).
  • The naive view also hides the genuinely dangerous edge, which is the other direction: a widely-depended-on module that itself depends on something volatile propagates that volatility to all twelve, and nobody drew that edge deliberately.
  • Left alone, this gets worse monotonically. Each new dependent raises the cost of every future change, and no single dependent is ever the one that made it expensive.
RequirementConstraintsInvariantsResponsibilitiesBoundariesInterfacesStateDependenciesFailureImplementationTestsFeedbackEvolution

What limits the solution, and what must never stop being true

This domain leads with these two. A design that ignores its constraints is not a design, and an invariant nobody named is one nothing is protecting.

Constraints
  • The twelve dependents belong to four teams, so any interface change is a scheduling problem before it is a code problem (Code Ownership).
  • Pricing genuinely changes often; that is the business, not a defect.
  • The build takes forty minutes when this module is touched, which is the practical enforcement mechanism whatever the design says.
Invariants
  • A change to something twelve modules depend on either preserves their behaviour or migrates them; it never silently alters what they compute.
  • Dependency edges have a direction, and the direction is a decision someone made — not an accident of who imported whom first.

Who owns what, and where the seams fall

Responsibilities decide boundaries; boundaries decide what an interface has to say.

Responsibilities
  • A module with many dependents owns a stable interface, a deprecation path, and the discipline not to leak its internals (Stable Boundaries).
  • A volatile module owns keeping its volatility to itself — few dependents, or a stable face.
  • Whoever adds a dependency edge owns justifying its direction, because that is the moment the decision is actually made and the only moment it is cheap.
Boundaries
  • Draw the boundary between "the question callers ask" and "the rules that answer it". The first should be stable; the second is free to churn (Designing a Module Interface).
  • If a stable module needs something volatile, the boundary is an interface the stable module owns and the volatile one implements — that is dependency inversion doing a job rather than being a rule (Dependency Inversion).
  • Configuration, clocks, feature flags and third-party clients are volatile by nature. A widely-depended-on module that reaches any of them directly has taken on their instability (Volatile Dependencies).

Two edges, and only one of them is dangerous

Draw the graph with rate of change written on each node rather than layer names, and the risky edges become obvious. An edge from something that changes weekly to something that changes yearly is fine — that is what dependencies are for. An edge the other way means the yearly thing now moves whenever the weekly thing does, and everything depending on it moves too.

The second picture is the same system after one change: the stable module defines the interface it needs and the volatile module implements it. No code has been deleted and nothing has become simpler; the compile-time arrow points the other way, and that is the entire mechanism.

  • Twelve dependents sit behind the interface node, not behind the rules node, which is why the weekly rule changes cost one module instead of twelve.
  • The genuinely risky edge is the last one: a yearly-changing module that reaches a third-party client inherits that vendor's release cadence and passes it to everything above (Volatile Dependencies).
  • Nothing here required a framework, a container, or a layer diagram. It required knowing which nodes change often, which is information your merge history already has (Change Amplification).
Volatility, and which way the arrows point
safe: volatile depends on stableimplements — the inversionsafeimports someone else's release cycleCheckout UI — changes weeklyPricing rules — change every sprintThe one dangerous edgePricing interface — changed twice in two yearsCurrency conversion — changes yearlyThird-party rate client — changes at their pace, not yours
UserLLMAgentToolDataDecisionHumanGuardrail

Signals of stability, and what each one is worth

SIMULATEDWhere Engineer Atlas displays any of these signals it computes them over a small modelled codebase to show the shape of the reasoning, not to benchmark anything. Real repositories differ in ways that matter — vendored code, generated files and monorepo tooling all distort import counts — so the same query on your own repository will need interpretation this teaching model does not.

People reach for a number here, and the numbers available are weaker than they look. Every signal below is a prompt to go and look at a module; none of them is a finding, and publishing any of them as a score would give an argument a precision it has not earned.

Use them the way you would use a smoke alarm: it tells you where to walk, it does not tell you what is burning, and a design review that consists of reciting ratios has replaced thinking with arithmetic.

SignalWhat it isWhat it is actually evidence ofHow it misleads
Inbound dependentsHow many modules import this oneCost of changing its interface — the most direct signal availableCounts imports, not reliance. A module imported for one constant is counted the same as one whose types are everywhere.
Change frequencyHow often the file has been edited in the last yearVolatility, and it is the best single signal here because it measures what actually happenedDescribes a phase. A module that churned during its build-out and is now finished looks identical to one that will churn forever.
Co-change with othersHow often it is edited in the same commit as another moduleWhether a boundary contains anything — high co-change across a boundary means the boundary is not where the change landsPicks up incidental co-editing: release chores, formatting sweeps and renames all inflate it (Change Amplification).
Instability ratioOutbound edges divided by total edgesVery little on its own; at best a way to sort modules before looking at themThe classic trap. It produces a tidy number from import counts and invites threshold-setting, which reorganises code without making any named change cheaper.
Interface churnHow often the module's public surface changed, as opposed to its internalsWhether the encapsulation is real — high internal churn with a still interface is exactly what you want (Encapsulation Radius)Needs a definition of "public" that your language may not give you, and is easy to game by widening the interface once and then never touching it.
Blast radius of a past changeWhat the last incompatible change to this module actually cost, in modules touched and days elapsedThe strongest evidence of all, because it is an observation rather than a proxyYou only get one data point per incident, and it arrives after you needed it.

Inverting one edge, concretely

Dependency inversion gets taught as a principle and is best understood as a specific, small move: the module that must not change defines the interface, and the module that changes often implements it. Nothing else about the code needs to move.

It is worth being clear about what this does not buy. The two modules still need each other at runtime, the wiring still has to happen somewhere, and if the volatile module has only ever had one implementation you have added an interface for one class — which is only worth it because of the direction of the compile-time edge, not because of any future second implementation (Dependency Inversion, Critically).

A stable module that needs a volatile rate source
Stable module imports the volatile one
// pricing/interface.ts  — twelve modules depend on this file
import { AcmeRatesClient } from '../vendor/acme-rates'

export function priceOf(b: Basket): Money {
  const rate = new AcmeRatesClient().latest(b.currency)
  // ...
}

// Acme ships a breaking client update.
// This file changes -> twelve dependents rebuild and re-test,
// and the vendor's release cadence is now yours.
Stable module owns the interface it needs
// pricing/interface.ts  — twelve modules depend on this file
export interface Rates {
  latest(c: Currency): Rate
}

export function priceOf(b: Basket, rates: Rates): Money { /* ... */ }

// vendor/acme-rates-adapter.ts  — nothing depends on this
export class AcmeRates implements Rates { /* ... */ }

// Acme ships a breaking update: one adapter file changes.
// pricing/interface.ts does not, so the twelve do not.

The behaviour is identical and the coupling has not disappeared — pricing still needs rates at runtime. What changed is which file has to be edited when the vendor moves, and therefore how many modules rebuild, re-test and re-review. The cost is one interface, one adapter, and wiring that now has to happen at a composition point instead of at the call site (Wiring and the Composition Root); the benefit is that a third-party release stops being a twelve-module event.

How to build it

Most important first.

  • Separate the stable question from the volatile answer. priceOf(basket): Money is stable; the two hundred lines of rules behind it are not, and the twelve dependents only ever needed the first (Designing a Module Interface).
  • Point dependencies toward things that change less often than the thing depending on them. That is the whole rule, and it is a statement about rates of change rather than about layers (Stable Dependencies).
  • Where a stable module genuinely needs volatile behaviour, invert: the stable side defines the interface, the volatile side implements it, and the compile-time edge now runs the safe way (Dependency Inversion).
  • Widen only deliberately. Every convenience method added to a widely-depended-on module is a permanent commitment, because twelve callers will find it (Exposing Too Much).
  • When you must change such a module incompatibly, expand and contract rather than editing in place: add the new shape, migrate dependents individually, remove the old one (Expand and Contract).

What the next change costs

The field this whole domain exists for. A structure is only better if it makes the change after this one cheaper — and it is worth saying which changes it does not help.

Cost of the next change
  • Before: a pricing rule change costs a forty-minute build, a full regression, and an email to four teams — every sprint, because pricing changes every sprint.
  • After: the same rule change costs one module and its own tests, because the twelve dependents cannot observe it. The interface change, when it eventually comes, still costs everything it costs today — and now it is rare rather than weekly.
  • The next *new* dependent is nearly free to add and permanently raises the cost of every future interface change. That is the compounding term, and it is invisible at the moment of adding.
  • An incompatible change to a twelve-dependent interface costs a migration window rather than an edit: two shapes live at once, twelve migrations, and a deletion that somebody has to remember to do (Deprecation).
What the recommended approach costs
  • A stable interface in front of volatile rules means a mapping layer and one more hop for every reader, forever.
  • Inverting a dependency adds an interface that must be maintained, and if it never gets a second implementation, some of that cost bought nothing (Dependency Inversion, Critically).
  • Keeping widely-shared modules narrow means saying no to reasonable requests from dependents, repeatedly, which costs goodwill rather than code.

What can go wrong

Failure modes
  • The stable interface is defined and then leaks the volatile types through it — a rules object, a config record — so the dependents are back inside the volatile part with an extra hop (Leaky Abstractions).
  • Stability is pursued for its own sake, and a module that should change is frozen instead, so workarounds accumulate in the twelve dependents rather than fixes in the one place (What Technical Debt Actually Is).
  • Someone computes an instability ratio, publishes it in a dashboard, and modules start being reorganised to move a number rather than to make a specific change cheaper.
  • The mitigation fails exactly here: teams invert a dependency to satisfy the rule and end up with an interface whose only implementation lives in the module that defines it, which is indirection with the coupling intact (Speculative Generality).
Dependencies, and their direction
  • Twelve inbound edges are the constraint. High fan-in is not a defect — it is what a shared concept looks like — but it converts every internal decision into a public one (Fan-in and Fan-out).
  • Outbound edges from a high fan-in module matter more than outbound edges from anywhere else, because each one is transitively inherited by all twelve dependents.
  • The organisational graph shadows the code graph: four teams behind twelve edges means a change is negotiated, whatever the coupling looks like on paper.
Misreads
  • "Stable means it should never change." Stable means *expensive to change*, which is a description of its position in the graph, not an instruction. A stable module that needs to change still changes; it just costs a migration (API Stability).
  • "Compute the instability ratio and enforce a threshold." Ratios of inbound to outbound edges count imports, and imports are a poor proxy for what actually forces change together. Treat any such number as a prompt to look at a module, never as a finding about it.
  • "Depend only downward, always." The rule is about rates of change, not about layer position. A "low-level" utility that wraps a third-party client whose API shifts every quarter is the most volatile thing in your system, and depending on it is the risky edge (Dependency Direction).
  • "So we should minimise dependencies." Dependencies are how modules reuse work; the goal is that each one points the right way and is narrow, not that there are few (Do We Need a Package for This?).
Smells this explains
  • shotgun-surgery
  • divergent-change

Testing it, and how it ages

What to test, and at which boundary
  • Test the stable interface thoroughly; it is the promise the twelve rely on and the thing that must not move (Contract Tests).
  • Test the volatile rules exhaustively and in isolation — they change weekly, so this suite is the one that earns its keep fastest (What a Unit Is).
  • Add a test that fails when a volatile type appears in the module's public surface, if your language allows it. An architecture test is cheaper than the review discipline it replaces.
How this design ages
  • Modules do not stay at one volatility. A module churns while its area is being built, then goes quiet; the dependency direction chosen during the churn is rarely revisited afterwards (Revisit Triggers).
  • High fan-in tends to increase, never decrease, so the correct time to narrow an interface is always earlier than it feels.
  • The signal that direction has gone wrong is that changes to a low-level detail keep forcing changes upward. That is visible in merge history long before anyone raises it in a design review (Change Amplification).

Where this applies

This domain's advice is contested more than most. These labels say what each claim is specific to — and where CONTESTED appears, the note gives the strongest form of the opposing view rather than a caricature.

  • GENERALThat a widely-depended-on module is expensive to change, and that depending on something more volatile than yourself imports its volatility, follows from what a dependency is and holds in any language with modules.
  • SIMULATEDAny dependency counts or volatility bands Engineer Atlas shows come from a model over a small teaching codebase, not from analysing production repositories. The shape — churn concentrated in a few modules, fan-in concentrated in a few others — is what transfers; treat the specific figures as illustration only.
  • CONTESTEDThe strongest opposing view holds that formal stability metrics — instability ratios, distance-from-the-main-sequence, and the dependency principles built on them — are numerology: they count import edges, which correlate weakly with what actually has to change together, and teams that optimise them reorganise code without making any named change cheaper. That criticism is largely correct about the metrics and largely wrong about the underlying observation, which is simply that changing something twelve modules use costs more than changing something nothing uses. The defensible position is to keep the observation and discard the arithmetic.

Where the depth lives

This domain teaches the codebase-level structure and hands the rest off.

Domains that do not exist yet
  • System Design — the same directional argument at service grain, where an unstable dependency is a service whose team deploys three times a day and whose contract you cannot pin.