MigrationGENERALSCALE-SPECIFICCONTESTED

Versioned Interfaces

An explicit version lets old and new consumers disagree about the contract. It also creates a maintenance obligation that lasts as long as the oldest version you have not managed to kill.

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

Should this interface carry an explicit version, or should it just never break?

The requirement

The internal reporting interface has changed shape three times in a year and each change caused an outage for one of its four consumers. Someone proposes versioning it.

The obvious build

Version everything. Put v1 in the path or the type name from the start, and then any change is a new version and nothing ever breaks.

Why it breaks

Versioning is cheap to add and expensive to keep. Each version is a code path, a test suite, a set of fixtures and a mental model, and the third one is where the maintenance becomes visible.

How it breaks as requirements change
  • Versioning is cheap to add and expensive to keep. Each version is a code path, a test suite, a set of fixtures and a mental model, and the third one is where the maintenance becomes visible.
  • It removes the pressure to converge. When breaking is free, changes get made that would not have survived the conversation about whether they were worth it, and consumers stop upgrading because they never have to (Deprecation).
  • The versions drift in behaviour rather than only in shape: a bug fix in v3 that is not applied to v1 means the two now disagree about the domain, and nobody can say which is right.
  • As requirements arrive, each must be implemented in every supported version or deliberately not, and "deliberately not" is a decision nobody records (Divergent Change).
  • Versioning by default also puts a version in interfaces that will never need one — internal module boundaries redeployed atomically — which is pure ceremony (Speculative Generality).
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 four consumers are internal, on the same deploy cadence, and owned by teams in the same building.
  • One of them, the finance export, cannot change inside a quarter because its output is reconciled against an external system.
  • The team maintaining the interface is three people, so every supported version is a real fraction of their capacity.
  • There is no API gateway or routing layer today; adding one is a bigger change than the versioning itself.
Invariants
  • A consumer pinned to a version must get that version's behaviour, exactly, for as long as it is supported. A version that quietly drifts is worse than no version.
  • Every supported version must be tested. An untested version is a claim, not a contract (Contract Tests).
  • The set of supported versions must be finite and each must have a stated end date.

Who owns what, and where the seams fall

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

Responsibilities
  • The version identifier owns exactly one thing: telling a consumer which contract it is talking to. It must not carry routing, feature or tenant meaning.
  • Each supported version owns a test suite that would fail if its behaviour changed.
  • Someone owns the sunset schedule and the consumer inventory, or versions accumulate and the count only goes up (Deprecation).
  • A single translation layer owns mapping each old version onto the current internal model, so the core knows one shape (Anti-Corruption Layer).
Boundaries
  • Versions belong at boundaries you do not control atomically. Inside a deployable, where every caller is redeployed with the callee, a version number is a note to yourself (Internal Module Contracts).
  • The translation boundary should sit at the edge: parse an old-version request into the current internal model on the way in, and render it back on the way out. Version-aware code in the middle of the domain is how versioning becomes unbounded (Boundary Adapters).
  • The unit of versioning is a decision: one version for the whole interface is coarse and simple; per-resource or per-message versions are precise and multiply.

When a version earns its keep

The question is not whether versioning is good. It is whether you can redeploy every consumer at once, and how much you are willing to pay to avoid having to.

The options below are ordered by cost, and the first one is the answer far more often than the reflex suggests. A version is the right tool for a change that cannot be made additively — which is a much smaller set than the set of changes people want to make.

The reporting interface must change shape. What carries the change?

Can every consumer be redeployed with this change, and if not, can the change be made additively?

No version — additive change only

when Consumers are internal and on a compatible cadence, or the change can be expressed as new fields alongside old.

cost Accreted optional fields and a contract that needs documentation to interpret. Zero ongoing maintenance, which is why this is the default (Backward Compatibility as a Constraint).

No version — coordinated break

when Four consumers, all in the same building, all able to ship the same week.

cost A coordination meeting and a synchronised release. Cheap at four consumers, impossible at forty. The finance export alone may make this unavailable here.

Two versions, with a dated sunset

when A consumer genuinely cannot move on your schedule — the quarterly finance export — and the change cannot be additive.

cost One translation layer, one extra test suite, and an owner who executes the sunset. The honest cost is the sunset, not the code.

Long-lived versions

when External consumers, contractual obligations, or a public API with unknown users.

cost Permanent. Every supported version is a code path and a decision per future change. Only take this on with a policy and a person, not as a technical default (Versioning: What a Version Even Promises in API Design owns the mechanics).

Version per resource or per message

when A large surface where parts evolve at genuinely different rates.

cost Precision bought with a combinatorial support matrix. Almost always worse than it sounds; try splitting the interface instead (Module Granularity).

Keep the version at the edge

Where the version is *allowed to be known* decides whether versioning stays bounded. Translated at the boundary, two versions cost one adapter. Branched on inside the domain, two versions cost a fork of the business logic, and the fork is permanent.

This is the same argument as anti-corruption layers, applied to your own past self: the old contract is a foreign model, and the domain should not have to know it exists.

Two supported versions, one internal model
1// edge: one adapter per supported version, each with a sunset date
2const adapters = {
3 v1: { parse: parseV1, render: renderV1, sunset: '2026-10-01' }, // finance export only
4 v2: { parse: parseV2, render: renderV2, sunset: null },
5}
6
7export function handle(v: 'v1' | 'v2', body: unknown) {
8 const req = adapters[v].parse(body) // -> internal model
9 const result = reporting.run(req) // knows nothing about versions
10 return adapters[v].render(result)
11}
12
13// what must never appear anywhere below this line:
14// if (version >= 2) { ...different business rule... }

The comment at the bottom is the design. Once a version test appears inside reporting, the domain has two behaviours and the sunset stops being a deletion of an adapter and becomes a refactor of the business logic. The sunset field being in the same object as the adapter is deliberate: the end date is part of the version's definition, not a ticket somewhere (Revisit Triggers).

What the version count actually costs

The scores below compare three positions on the same interface, for this situation: four internal consumers, one of them slow, three maintainers. They are judgements about this case, not measurements of anything.

The axis that decides it is migration, and it points the opposite way from intuition: versioning scores well on the migration of *consumers* and badly on the migration of the *interface itself*, because every internal change must still be renderable into every supported version.

Three positions on the reporting interface
OptionSimplicityFlexibilityTestabilityOperationalMigration costNote
Never break, additive onlyOne code path, one test suite, no sunset work. Pays in accreted optional fields and in changes that cannot be made at all. Ages into an interface only its maintainers can read.
Two versions, dated sunsetThe finance export gets its quarter; everyone else moves. Costs one adapter and a person who actually executes the sunset — which is the part that fails, not the code.
Open-ended versioningMaximum consumer autonomy, and every future change is priced per supported version. The migration score is the lowest because the interface can no longer evolve internally without satisfying all of its own history.

caveat None of these numbers captures the only thing that decides it: whether your organisation can execute a sunset against a consumer that does not want to move. If it can, the middle option is strictly best and the version count returns to one. If it cannot, the middle option *is* the third option with optimistic labelling, and it should be scored as such. That is a question about leverage and ownership, not about interfaces (Code Ownership).

How to build it

Most important first.

  • Default to never breaking, and version only when you have a concrete change that cannot be made additively. Additive evolution costs nothing to maintain; a version costs forever (Backward Compatibility as a Constraint).
  • When you do version, decide the sunset date at the same moment you create the version, and write it in the same commit. A version created without an end date will not get one later.
  • Support two versions, briefly. Two is a migration; three is a product line; four means nobody is being made to move.
  • Translate at the edge and keep exactly one internal model. The alternative — branching on version deep in the domain logic — is the failure this whole design exists to prevent.
  • Make the version explicit rather than inferred. A version guessed from the shape of a payload is a heuristic that will be wrong at the worst moment.
  • Measure who is on which version, in production. Sunsets are executed against telemetry, not against intentions.

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
  • With no versions and additive-only evolution: a change costs one implementation plus the compatibility care, and the cost does not grow over time.
  • With two supported versions: a change costs one implementation plus a translation, and — this is the part people underestimate — a decision per change about whether the old version gets the new behaviour.
  • With five: a change costs a meeting. The maintenance is superlinear because the interactions between versions must also be considered, and the test matrix grows with them.
  • The version count is therefore the single number that predicts future change cost on this interface, and it is the number nobody tracks (Change Amplification).
What the recommended approach costs
  • Refusing to version means some changes cannot be made at all, or must be made additively in ways that leave the interface less coherent than a clean break would.
  • Versioning buys consumer autonomy with maintainer cost, and the two are different people — which is why the decision is usually made by whoever is in more pain, rather than by whoever pays.
  • Additive-only evolution accumulates deprecated-but-present fields, and a newcomer reading the interface cannot tell which are current without documentation that will decay (Documentation Decay).

What can go wrong

Failure modes
  • Versions accumulate. Five supported versions, each with a code path, and a new requirement now costs five implementations or four explicit refusals.
  • A bug fix lands in the current version only, so old versions become subtly wrong, and a consumer's "upgrade" changes behaviour they had come to depend on.
  • The version leaks inward: if (version >= 3) appears in domain logic, and now the business rules depend on the wire format (Invariant Leaks).
  • Nobody knows who is on v1, so the sunset never happens and v1 becomes permanent — the single most common end state.
  • The mitigation fails on its own terms: an aggressive sunset policy is announced, a consumer cannot meet it, an exception is granted, and the policy is now advisory.
Dependencies, and their direction
  • Every supported version is a dependency of the current code on a past decision, and it constrains refactoring: the internal model cannot change in a way no old version can be rendered from.
  • The sunset depends on consumers you may not control, so the version count is partly a function of your organisational leverage rather than your engineering (Dependency Direction).
  • Version-aware translation depends on knowing the complete historical shape, which means old fixtures must be kept and kept runnable.
Misreads
  • "Semantic versioning solves this." SemVer communicates the *kind* of change; it does not reduce the number of supported versions or tell you when you may delete one (Semantic Versioning).
  • "Internal interfaces should be versioned too, for consistency." Inside a deployable, where callers and callee ship together, a version number adds ceremony and no capability. The question is always whether you can redeploy every consumer at once.
  • "Versioning avoids breaking consumers." It relocates the break to the sunset, where it happens with notice. That is genuinely better, but it is not the absence of a break — and a sunset that never comes means the break was replaced by permanent cost.
  • "More versions means more flexibility." More versions means more of the codebase is committed to past decisions. The flexible system is the one with one version and a consumer base that upgrades.
Smells this explains
  • divergent-change

Testing it, and how it ages

What to test, and at which boundary
  • A contract test suite per supported version, run in CI, built from fixtures captured when that version was current (Contract Tests).
  • A test that the internal model can still be rendered into every supported version — this is what catches an internal refactor that quietly breaks v1.
  • Telemetry as a test: assert in production which versions are actually in use, and alert when a supposedly-dead version receives traffic.
  • Test the translation layer at the boundary, not the domain logic per version, or you will have N copies of the same domain tests (What a Unit Is).
How this design ages
  • Version counts ratchet up unless something forces them down. The forcing mechanism has to be organisational — a policy, a date, a named owner — because there is no technical pressure to remove a version that still works.
  • Interfaces that started internal and unversioned often acquire an external consumer, at which point versioning becomes necessary retroactively and much more expensive (Public vs Internal APIs in API Design).
  • Over a long enough life, the translation layer becomes the most valuable code in the system: it is the only place that knows what the shapes meant historically.

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 an explicit version buys consumer independence at the price of maintaining every version you have not retired holds for HTTP APIs, message schemas, plugin interfaces and library APIs alike.
  • SCALE-SPECIFICWith four internal consumers on a shared cadence, coordinated additive change is cheaper than versioning and the version count should be one. With hundreds of external consumers on their own schedules, coordination is impossible and versioning is the only mechanism available. The advice inverts entirely between those two, and most arguments about it are two people describing different situations.
  • CONTESTEDThe strongest opposing view: versioning early is cheap insurance, and teams that commit to never breaking end up with interfaces carrying a decade of vestigial fields, optional-everything payloads and undocumented conventions — an unversioned interface that has changed twenty times is a set of implicit versions with no name for any of them. Practitioners who maintain widely-consumed APIs argue an explicit version is more honest than an accreted one. The counter is that the accretion is visible and finite while an abandoned v1 is invisible and permanent; both failure modes are real, and which is worse depends on whether your organisation can actually execute a sunset (API Stability).

Where the depth lives

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

Domains that do not exist yet
  • Programming Languages & Runtime Internals — library and ABI versioning is the same problem with the linker as the enforcement mechanism, and its symbol-versioning solutions are worth knowing before inventing one.