Dep MgmtSCALE-SPECIFICLANGUAGE-SPECIFICCONTESTED

API Stability

An interface with no external users still has consumers. Changing it frequently across many of them costs coordination, and that cost is invisible in every individual diff.

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 internal and we can change it whenever we like — so why does changing it keep costing us a week?

The requirement

The Pricing module's signature needs to change to carry a currency. It is internal code, in the same repository, with nineteen call sites across four teams.

The obvious build

Semantic versioning, deprecation windows and compatibility promises are for public libraries. This is our own code in our own repository; we can just change it and fix the callers in the same commit.

Why it breaks

You can change it. The cost is not in the change, it is in the nineteen updates, and those are not yours to schedule (Change Amplification).

How it breaks as requirements change
  • You can change it. The cost is not in the change, it is in the nineteen updates, and those are not yours to schedule (Change Amplification).
  • The single commit that updates everything is enormous, touches four teams' code, and is unreviewable by anyone who understands all of it — because nobody does.
  • The pattern repeats. A module whose signature changes monthly imposes a monthly tax on every consumer, and none of the individual changes ever looks unreasonable enough to refuse.
  • It also silently discourages reuse: teams start copying rather than depending, because depending means being interrupted. You get duplication caused by instability, which no code review will diagnose correctly (Duplicate Knowledge).
  • In a polyrepo the same instability becomes worse, not better: instead of one big commit you get nineteen version bumps in nineteen repositories, arriving over months, and now several versions of the interface are live simultaneously.
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 nineteen call sites belong to four teams with four backlogs; you cannot merge their work for them.
  • The repository is a monorepo with a single build, so the change and all nineteen updates must land together or the build is red (Monorepo vs Polyrepo).
  • Two of the four teams are mid-sprint on something unrelated and will not reprioritise this week.
Invariants
  • The build must be green on every commit on the main branch, so no intermediate state may exist in which some callers are updated and others are not — which is the constraint trunk-based development imposes, and DevOps owns the mechanics of it.
  • Prices computed during the transition must be identical to prices computed before it. A signature change is not permission to change behaviour (What Refactoring Actually Is).

Who owns what, and where the seams fall

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

Responsibilities
  • The module owner owns the interface as a product, with the consumers as users — even though nothing is published and nobody pays.
  • The consumers own keeping up, but only in proportion to how much notice and how much migration help they were given.
  • Somebody has to own the *tier* — the explicit statement of how stable this interface claims to be — because without it every consumer assumes the level of stability that suits them (Internal Module Contracts).
Boundaries
  • A boundary becomes a contract at the point where the number of consumers times the frequency of change exceeds what one person can coordinate in a morning. Below that line, informality is correct and cheaper.
  • Not every module boundary is such a contract. Most internal seams have one or two callers and should be freely changeable; treating all of them as contracts is how a codebase gets slow to change (Over-Decomposition).
  • The useful discipline is to be explicit about which of your seams are contracts and which are not, so consumers can tell the difference without asking (Stable Boundaries).

Three tiers, and what each one promises

The single cheapest intervention is a line in the module's README saying what it claims. Consumers currently guess, and they guess differently: one team treats your helper as a foundation and another expects it to move weekly, and both are surprised.

Three tiers is enough, and more than three stops being read. What matters is not the taxonomy but that the promise is written where a consumer will see it before they depend on it.

  • The tier is a claim about *your* behaviour, not about the code's quality. A well-written module can honestly be experimental and a bad one can honestly be frozen.
  • Promoting a module from experimental to stable should be a deliberate act with a review, because it is the moment you give up the ability to fix your own mistakes cheaply.
  • Demoting is allowed and almost never done, which is why codebases end up full of stable-labelled interfaces that nobody has kept stable.
TierWhat the owner promisesWhat a consumer should doWhen it is the right label
ExperimentalNothing. May change or disappear without notice.Depend from one place, behind your own function, and expect to fix it.The first two consumers, while the shape is still being learned. Most new internal modules should start here and most never say so.
StableAdditive changes only. Removals are announced with a migration path and a window.Depend freely. Read the changelog.Once there are more consumers than you can personally message in a morning.
FrozenNo changes except bug fixes. New capability goes in a new interface alongside.Depend without thinking about it.Interfaces at the centre of the dependency graph — ids, money, time, auth — where the cost of any change exceeds any plausible improvement.

The shape that makes the common change free

Most instability is self-inflicted and mechanical. Positional parameters and tuple returns mean that the most frequent change — one more piece of information — is a breaking change; named shapes mean the same change touches nobody.

This is not a style preference. It is the difference between a change that costs one edit and a change that costs nineteen, and it is decided when the interface is first written, by someone who has no idea how many consumers are coming.

Adding currency to a price calculation
Positional parameters, tuple return
export function price(
  sku: string,
  qty: number,
  country: string,
): [number, number] { /* total, tax */ }

// Adding currency:
//   price(sku, qty, country, currency)
// -> every one of 19 call sites must change
// -> the tuple grows: is [1200, 240, 'EUR'] total-tax-currency
//    or total-currency-tax? The compiler cannot tell you.
Named request, named result
export interface PriceRequest {
  sku: string
  qty: number
  country: string
  currency?: Currency   // added later; defaults to the country's
}
export interface Price {
  total: Money
  tax: Money
  currency: Currency    // added later; existing readers ignore it
}
export function price(req: PriceRequest): Price

The named shape makes the *anticipated* change — one more input, one more output — additive, so nineteen consumers keep compiling and adopt the new field when they need it. It is not free: the request object is more ceremony at every call site, the optional field means the type no longer tells you what is actually required, and a change of *meaning* (total now includes tax) breaks all nineteen just as badly and silently. It buys locality for the common change, not for every change.

Pricing the coordination, not the diff

The reason this lesson exists is that the cost is invisible where the decision is made. The module owner sees a two-line signature change and reasonably calls it small. The organisation pays for nineteen edits, four teams' context switches, and a week of elapsed calendar time.

Putting both numbers next to each other is the entire argument, and it is also how you decide that some interfaces genuinely should be broken — because for a module with two consumers, the coordination cost is a message on Slack and the informal approach wins outright.

Pricing must carry a currency
The change

Every price must be produced with an explicit currency rather than assuming the country's default. The rule itself is a day of work.

Positional parameters, tuple return, nineteen direct call sites across four teams
PricingCheckoutCartApiInvoicePdfAdminOrdersRefundServiceExportJobSubscriptionRenewalQuoteBuilder
testsnineteen call-site tests, plus every fixture that constructs the tuple by position
9 modules · 1 test file

One large commit spanning four teams' code, reviewable by nobody who understands all of it, blocked on two teams who are mid-sprint. The day of pricing work takes a week of calendar time and none of the extra time is spent on pricing.

Named request and result, declared stable, currency added as an optional field with a default
Pricing
testspricing_test, extended with the currency casesone contract test asserting existing callers still get the old default
1 module · 2 test files

One commit from one team. The nineteen consumers keep compiling and are unaffected; each adopts the field when its own feature needs it, on its own schedule.

what it cost The default is a lie that will eventually be found out: a consumer that never passes a currency is silently assuming one, and when a customer in Switzerland is charged in euros, the type system will have said nothing. The additive design converted a loud, expensive, synchronous breakage into a quiet, cheap, asynchronous risk — which is usually the better trade and is emphatically not the same as being safe. Getting the loud version requires making the field required later, which is the deprecation problem all over again.

How to build it

Most important first.

  • Publish a stability tier per module, in one line, next to the code. Three tiers is enough: experimental, stable, and frozen. The tier tells a consumer what to expect and tells you what you owe (Docs Close to Code).
  • Add before you change, and remove after. A new parameter with a default, a new function alongside the old one — the expand-and-contract shape works exactly as well inside a repository as across a network (Expand and Contract).
  • Make the interface narrow enough to be stable. Wide interfaces are unstable because there is more surface to be wrong about, and every extra exported symbol is a promise (Exposing Too Much).
  • Prefer additive shapes: an options object rather than positional parameters, a named result rather than a tuple, so the common change — one more field — does not touch any caller (Introduce Parameter Object).
  • Batch breaking changes. Four breaking changes released together cost consumers one migration; released separately they cost four, and the total amount of breakage is identical.
  • Give consumers the migration, not just the notice. A codemod, a script or even a worked example converts a week of four teams' time into an afternoon of yours (Incremental Migration).

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
  • Under an unstable wide interface: a signature change costs nineteen edits, four teams' attention, one very large commit and roughly a week of elapsed time, most of it waiting.
  • Under a narrow, additive interface: the same requirement is a new optional field with a default, costing one edit and zero consumer time. Consumers adopt it when they need it.
  • Under a declared-stable interface with expand-and-contract: the change costs two commits from you (add, then later remove) and a scheduled window, but zero synchronised coordination — you trade elapsed time for other people's calendars, which is nearly always the right trade.
  • What does not get cheaper: a genuine change of *meaning*. If prices now include tax, no additive shape protects consumers, because their code is wrong in a way the compiler cannot see. That change costs a conversation regardless of interface design, and pretending otherwise is how silent breakage happens (Backward Compatibility as a Constraint).
What the recommended approach costs
  • Stability is bought with your own flexibility. A module you cannot change is a module that cannot be improved, and some interfaces genuinely deserve to be broken because they were wrong (Stable Dependencies).
  • Additive-only design accumulates cruft: optional parameters, deprecated fields and two ways to do the same thing, all of which make the interface harder to learn for people who arrive later.
  • Declaring tiers is process, and process on a five-person team where everyone knows every call site is pure overhead. The mechanism only pays once the consumers are people you have to schedule with.

What can go wrong

Failure modes
  • The stability tier is declared and ignored, so "stable" means nothing and consumers learn to distrust the label — the mitigation becoming noise.
  • Everything is declared stable defensively, and the codebase freezes: nothing can be improved because everything is a promise. This is over-correction and it is common in teams recovering from the opposite problem.
  • The additive change is made — a new optional parameter — and the old path is never removed, so the interface accumulates parameters that exist only for compatibility and nobody can tell which are current (Long Parameter List).
  • The owning team measures its own stability by how often it makes breaking changes, which is the wrong denominator. The cost is breaking changes times consumers, and a rarely-changed interface with two hundred consumers is more expensive than a weekly one with two.
Dependencies, and their direction
  • Every consumer depends on the shape of your interface, whether or not that is written down anywhere. Fan-in is the variable that turns a private function into a contract (Fan-in and Fan-out).
  • You depend on your consumers for release: a stable interface is one you cannot change without their cooperation, which is a dependency pointing the wrong way and the real cost of stability.
  • The build system is a participant. Whether all consumers must compile against the new version at once is decided by repository topology, not by design (Monorepo vs Polyrepo).
Misreads
  • "Internal means we owe nothing." You owe your colleagues the same thing you owe an external user: enough notice to plan. The difference is that they cannot leave, which makes it easier to be careless, not more acceptable (Code Ownership).
  • "So version everything internally." Internal version numbers on modules inside one build are usually ceremony — the build already pins everything to one commit. What you need is a stated tier and a migration path, not a number (Semantic Versioning).
  • "A monorepo solves this." It changes the shape of the cost from "many live versions" to "one enormous synchronised commit". Both are real, and which is worse depends on how many teams you must coordinate (Monorepo vs Polyrepo).
  • "Stable means never change it." Stable means changes are additive and removals are announced. A frozen interface that is wrong is a liability, not an achievement.
Smells this explains
  • shotgun-surgery
  • long-parameter-list

Testing it, and how it ages

What to test, and at which boundary
  • Test the interface at its own boundary, so that internal restructuring does not break tests and genuine signature changes do (What a Unit Is).
  • For a module with many consumers, a small contract test owned by the module and run against the shapes consumers actually use makes breakage a CI failure rather than a discovery (Contract Tests).
  • Test the compatibility shim during expand-and-contract, not just the new path — the old path is what the nineteen callers are still on.
How this design ages
  • Interfaces stabilise by accumulating consumers, whether or not anyone intends it. Stability is a consequence of fan-in, and it arrives before anyone declares it.
  • The natural life cycle is experimental for the first two consumers, de facto stable by the fifth, and formally frozen when the cost of a breaking change exceeds the value of any improvement. Most teams notice this one stage late.
  • When a stable interface finally must change fundamentally, the answer is a second interface alongside the first, not a version of the first — which is where this lesson hands over to deprecation (Deprecation).

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.

  • SCALE-SPECIFICWith five engineers and one team, the informal approach is correct: change the signature, fix the callers, ship. Stability tiers and migration shims are overhead that buys nothing, because coordination cost is a conversation. The mechanisms in this lesson start paying somewhere around the point where the consumers of a module are people whose sprints you do not control — typically three or four teams — and importing them earlier is a real and common way to make a small codebase slow.
  • LANGUAGE-SPECIFICIn a statically typed language with a whole-repository build, a breaking internal change is a compile error and the cost is bounded and known. In a dynamically typed language, or across a network, the same change is discovered at runtime by whichever consumer exercises it first — so the argument for explicit tiers and contract tests is much stronger, because the compiler is not doing the coordination for you.
  • CONTESTEDA strong opposing view holds that internal stability guarantees are premature bureaucracy: inside one repository the right answer is to change the interface and fix every caller in the same commit, because that keeps exactly one version of the truth alive and forces the cost to be paid immediately rather than accumulating as shims. Teams that work this way — and some very large monorepos do — argue that deprecation windows are how interfaces rot. They are right that the shims are a real cost; the disagreement is about whether four teams can realistically be interrupted on your schedule.

Where the depth lives

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

Domains that do not exist yet
  • Testing & Reliability Engineering — whether a breaking internal change is caught at build time or in production is a testing-boundary question, and it decides how much of this lesson's machinery you actually need.