Stable Dependencies
Depend toward the things that change less often than you do. It is a statement about rates of change, not about which folder sits lower in a diagram.
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.
Which way should this dependency point, and what makes the other direction wrong?
A platform/ package that nothing was supposed to depend on now imports the feature-flag client and a company-specific tenant type. Every flag-service upgrade rebuilds and re-tests the entire repository, and a change to the tenant model does the same.
Put low-level things at the bottom and depend downward. The flag client is infrastructure, so it belongs in platform/.
"Low-level" and "stable" are different properties, and this is the mistake the whole lesson exists to name. A third-party client is low-level and among the least stable things you have, because its release schedule is not yours (Volatile Dependencies).
- "Low-level" and "stable" are different properties, and this is the mistake the whole lesson exists to name. A third-party client is low-level and among the least stable things you have, because its release schedule is not yours (Volatile Dependencies).
- Anything nine packages import inherits its dependencies to all nine, so one volatile import at the bottom of the diagram distributes its instability to everything above it.
- The tenant type is worse still: it is a domain concept with business reasons to change, sitting in a package defined as having none. Every tenancy change now rebuilds the world (The Common Module).
- The twenty-five-minute suite is the visible symptom, and it will be attacked as a build problem — parallelism, caching, test selection — rather than as the dependency-direction problem it is.
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.
- The flag client is a third-party library that ships breaking changes roughly twice a year, on their schedule (Transitive Dependencies).
- Nine packages import
platform/, across four teams. - The full test suite takes twenty-five minutes when
platform/changes and ninety seconds otherwise, which is the only feedback anyone actually reacts to.
- A package that many depend on does not import anything that changes more often than it does.
- The dependency graph between packages is acyclic and its direction is a decision, not an accident (Circular Dependencies).
Who owns what, and where the seams fall
Responsibilities decide boundaries; boundaries decide what an interface has to say.
platform/owns mechanism with no business meaning and no volatile third-party surface: a clock, id generation, an HTTP construction helper.- A capability owns its own domain types, including the tenant type, and other capabilities depend on that owner rather than on a neutral location (Package Design).
- Whoever adds an import to a widely-depended-on package owns justifying it, because that import is inherited by everything downstream.
- The boundary worth defending is around the shared kernel, and it is defended by an admission rule rather than by good intentions.
- Volatile third-party clients belong behind an adapter owned by whoever needs them, not in a shared package (Boundary Adapters).
- Where a stable package genuinely needs volatile behaviour, it defines the interface and the volatile side implements it (Dependency Inversion).
The diagram lies about which things are stable
Redraw the package graph with observed change frequency on each node instead of layer names, and the risky edges stop being a matter of opinion. What looks like a well-behaved downward dependency is frequently an edge pointing at the most volatile thing in the repository.
The two marked edges below are the whole problem, and neither is visible on a conventional layered diagram — on that drawing they both point downward, which is what a layered diagram calls correct.
- The kernel importing the flag client means every vendor upgrade rebuilds and re-tests nine packages, twice a year, forever.
- The kernel importing
Tenantis worse, because tenancy is a business concept that changes for business reasons — the kernel now has a domain reason to change (The Common Module). - Both edges reverse the same way: the capability that needs the flag client owns an adapter for it, and
Tenantstays inidentity/with the kernel knowing nothing about it (Boundary Adapters).
What reversing two edges is worth
This change adds files. It is worth being clear about that, because the pitch is not "simpler" — it is that a recurring, expensive event becomes a cheap one, and the price is one adapter and one moved type.
The vendor upgrade is the right change to price, because it is the one that actually recurs. Pricing a hypothetical replacement of the flag vendor would be the speculative version of this argument and would prove much less.
The vendor renames its evaluation API, changes the shape of its context object, and drops a deprecated method. The upgrade is not optional because the old version stops receiving security patches.
Every package that touches a flag has the vendor's call shape in its own code, so all of them edit. The twenty-five-minute suite runs, four teams review, and the upgrade takes a fortnight of calendar time for two days of work.
One file changes. The interface — isEnabled(flag, subject) — is yours and did not move, so nothing downstream rebuilds beyond the normal graph, and one team reviews it.
Tenant into identity/ means eight packages now import a capability rather than a neutral kernel, which looks worse on a diagram and is better in every rebuild.Writing the rule down so it survives
Dependency direction is not maintainable by review discipline. It degrades one reasonable pull request at a time, and no individual reviewer ever sees the aggregate. The only version that lasts is machine-checked.
The policy below is deliberately short. A long one becomes an exception list, and an exception list is a description of the architecture you have rather than the one you decided on.
1{2 "forbidden": [3 { "name": "kernel-imports-nothing-of-ours",4 "from": { "path": "^src/platform/" },5 "to": { "path": "^src/(?!platform/)" },6 "comment": "platform/ is mechanism only: no domain, no vendors" },7 8 { "name": "no-cross-package-internals",9 "from": { "path": "^src/([^/]+)/" },10 "to": { "path": "^src/(?!\\1/)[^/]+/(?!api\\.ts)" },11 "comment": "packages talk through api.ts only" },12 13 { "name": "no-cycles",14 "from": {}, "to": { "circular": true } }15 ]16}Three rules, and the third is the one that pays for the file on its own. What makes this work is that it fails the build rather than warning: a rule that only warns is a rule that is off. Note also what is not here — no numeric thresholds and no invented coupling numbers. The policy expresses directions that were decided by looking at what changes together, and the tool only stops them drifting (What to Automate Out of Review).
How to build it
Most important first.
- Rank packages by observed rate of change using merge history, not by their position in a diagram. That ranking is usually not what the architecture drawing implies (Stability and Dependency Direction).
- Point every dependency from the faster-changing package to the slower-changing one, and treat every exception as a decision that needs writing down.
- Give the shared kernel an explicit admission rule — no business concepts, no third-party clients, no configuration — and enforce it in review and in CI (What to Automate Out of Review).
- Move domain types out of the kernel into the capability that owns them, even when several capabilities use them. An arbitrary owner beats a neutral dumping ground (Package by Feature).
- Wrap volatile third-party surfaces in an adapter owned by the capability that needs them, so the vendor's release cadence reaches one package (Anti-Corruption Layer).
- Encode the rules as a machine-checked dependency policy, because this is a property nobody maintains by discipline for more than a quarter.
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.
- Before: a flag-client upgrade costs a full-repository rebuild, a twenty-five-minute suite and nine packages worth of regression risk, twice a year, plus the same on every tenancy change.
- After: a flag-client upgrade costs one adapter and its tests. A tenancy change costs the owning capability and whoever explicitly depends on it, which is fewer than nine.
- The next new capability is cheap to add and inherits only the kernel, so the property improves rather than degrades as the system grows — which is the opposite of what happens when the kernel is unguarded.
- The cost that stays: a change to the kernel itself is still expensive, and should be, because nine packages depend on it. That expense is now proportional to how rarely it happens (API Stability).
- Adapters around third-party clients are mapping code that must be written and maintained, and for a dependency you never replace they buy only insulation from its release schedule.
- Putting a shared domain type inside one capability means the others acquire a dependency that can look arbitrary on a diagram, and someone will propose moving it back to a neutral package every year.
- A machine-checked dependency policy generates friction on legitimate work and needs an exception process, which is ongoing overhead (What to Automate Out of Review).
What can go wrong
- The kernel keeps its name and loses its meaning:
platform/acquires domain types one reasonable pull request at a time, and nobody can point at the moment it stopped being a kernel. - A dependency policy is added, three exceptions are granted for expedience, and the exception list becomes the actual architecture.
- The direction is fixed but the co-change is not: a capability now defines an interface the kernel implements, and both still change together on every tenancy change (Dependency Inversion, Critically).
- The mitigation fails on its own terms: the volatile client is wrapped in an adapter, but the adapter's interface is a copy of the vendor's API, so a breaking vendor change breaks the interface anyway (Leaky Abstractions).
- Inbound edges to the kernel are many and that is fine; outbound edges from the kernel are the ones that need justifying, since each is inherited by everything.
- A capability depending on another capability is acceptable when it is one-way and named; the direction should run toward whichever changes less often (Dependency Direction).
- Test utilities are a common blind spot: a shared test helper importing a volatile client couples the entire suite to it, with no production edge to show for it.
- "Depend downward on the layer diagram." The diagram encodes an assumption about stability that is frequently false. Rank by observed change frequency instead; the vendor client at the bottom is often the most volatile thing you own (Stability and Dependency Direction).
- "Stable means unchanging." Stable means expensive to change because many things depend on it. Stable packages still change; they change with a migration (API Stability).
- "Minimise dependencies." The goal is that each dependency points toward something slower-moving and is narrow. A package with few dependencies pointed the wrong way is worse than one with many pointed correctly (Do We Need a Package for This?).
- "Put shared types in a types package." A package named after a language feature has no reason to change of its own and therefore collects everything, which reproduces the problem this lesson is fixing (The Common Module).
- utility-dumping-ground
- shotgun-surgery
Testing it, and how it ages
- A dependency-policy test in CI, expressed as rules rather than as a lint suppression list — it is the only enforcement that survives a deadline (What to Automate Out of Review).
- Kernel tests with no domain concepts anywhere in them. If a kernel test needs a tenant, the kernel has acquired a business reason to change (What a Unit Is).
- A contract test around each third-party adapter, so a vendor upgrade fails in one place with a clear message rather than in nine with confusing ones (Contract Tests).
- Kernels grow monotonically unless someone is accountable for their size; nothing in the normal flow of work ever removes anything from one (Stability and Dependency Direction).
- Volatility rankings shift: a package that changed weekly during its build-out goes quiet, and the dependency direction chosen during that period is rarely revisited (Revisit Triggers).
- The signal that the direction has gone wrong is that changes to a "low-level" package keep forcing changes upward, which shows up in merge history well before anyone raises it (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 depending on something more volatile than yourself imports its change rate, and that everything depending on you inherits it, follows from what a dependency is and holds in any language with packages.
- SIMULATEDWhere Engineer Atlas shows package volatility rankings or rebuild counts, they come from a model over a small teaching repository rather than from instrumenting real builds. The shape — churn concentrated in a few packages, fan-in concentrated in others — transfers; the figures are illustration, and your own merge history is the only source worth acting on.
- CONTESTEDThe strongest opposing view holds that formal stability principles are dressed-up import counting: they rank packages by graph position rather than by what genuinely changes together, and teams that enforce them reorganise code to satisfy a rule without any named change becoming cheaper. That criticism lands squarely on the metric-driven version of this idea. It does not touch the underlying observation, which is only that changing something nine packages import costs more than changing something nothing imports — so the defensible practice is to rank by observed churn and drop the arithmetic.
Where the depth lives
This domain teaches the codebase-level structure and hands the rest off.
- — System Design — the same directional argument between services, where an unstable dependency is a team that deploys three times a day and whose contract you cannot pin.