Changing a shared contract
A contract is any shape that more than one thing depends on: an API schema, a shared types package, an event payload, a stored document, a CSV a customer parses. The failure is always the same — one side changes, the other finds out in production.
1. Enumerate the consumers before you touch it
Write the list. It is always longer than the two you were thinking of:
- The obvious callers — the web app, the mobile app, the service next door.
- Mocks, stubs and local development servers that reimplement the contract.
- Test fixtures and recorded responses.
- Generated clients, SDKs and typed hooks.
- Reporting queries, exports, dashboards and the analytics pipeline.
- Anything replaying stored events, which means old payloads already written.
- Third parties, who will not be redeployed because you said so.
- Cached bundles and installed mobile apps — versions of your own frontend still running in the wild.
That last group is the one that turns a "coordinated deploy" into an outage. You control the server; you do not control which version of the client is open.
2. The local mock is the one that drifts
A hand-maintained local development server, an in-memory fake, or a stub that mirrors the real schema will drift, because nothing fails when it does. Everyone develops happily against a contract that no longer matches production.
Two acceptable answers:
- Generate it. One source of truth; the mock is derived. Drift becomes impossible rather than unlikely.
- Test it against the real thing. A contract test that runs the same suite against the mock and against a real instance, in CI. Drift becomes loud.
"We'll remember to update both" is not one of them. If neither is affordable
today, at least put a line in the instruction file saying the mirror exists and
must be updated — see agent-instructions.
3. Additive is safe; removing and renaming are not
| Change | Safe? |
|---|---|
| Add an optional field | Yes |
| Add a new endpoint, event type or enum producer side | Usually — check how consumers handle unknown values |
| Make an optional field required | No — old clients omit it |
| Remove a field | No — something reads it |
| Rename a field | No — this is a remove plus an add, and the remove is what breaks |
| Change a type (string to number, seconds to milliseconds) | No, and the worst kind: it fails silently and corrupts data |
| Change the meaning of a field, same type | No, and undetectable by any tooling |
| Tighten validation | No — data that was accepted yesterday is rejected today |
| Add a new enum value | Only if consumers were written to tolerate unknown values |
The last row is worth designing for up front: consumers that reject unknown enum values make every future addition a breaking change.
4. Expand, migrate, contract
Any unsafe change becomes safe as three deploys:
1. EXPAND Add the new field alongside the old. Write both. Read the old.
Ship. Nothing breaks.
2. MIGRATE Backfill. Move readers to the new field one at a time. Ship each.
Keep writing both.
3. CONTRACT When nothing reads the old field — proven, not assumed — stop
writing it, then remove it. Ship.
Each step is independently deployable and independently revertible. This is slower than a rename and it is the difference between a change and an incident.
The step people skip is proving nothing reads the old field. Assume something does until telemetry says otherwise.
5. Types passing is not compatibility
A green type check proves that the code in this repository agrees with itself right now. It says nothing about:
- The mobile app version from three months ago.
- A cached JavaScript bundle in someone's browser.
- Events already written to a queue or a log.
- Documents already stored under the old shape.
- A third party's integration.
Compatibility is a statement about data that already exists and clients already running. Reason about it explicitly; the compiler cannot.
6. Version when you must, additive when you can
Versioning an API is expensive — two code paths, two sets of tests, two things to deprecate — so exhaust additive changes first. Most "we need v2" moments are three additive changes and a deprecation.
When you do version:
- Version the whole surface or an individual endpoint, consistently.
- Set the end date for the old version when you launch the new one, not later.
- Instrument usage so the shutdown is a fact, not a hope.
- Tell the consumers who are actually using it, identified by telemetry.
For stored documents, prefer a version field on the record and a reader that
handles each known shape — the same idea as versioned-publishing, applied to
data rather than content.
7. Deprecate visibly
A deprecation nobody notices is not a deprecation:
- Mark it in the schema and in the docs, with the date it goes.
- Log or emit a metric on every use, tagged with the caller.
- Warn in the client — a runtime warning is seen; a changelog entry is not.
- Only remove when usage is zero, and say who the remaining callers are if it is not.
8. One contract test both sides run
The highest-value artifact here: a suite that the producer runs against itself and every consumer runs against its expectation of the producer. It fails on the side that broke, in CI, before anything ships.
Where a full contract-testing tool is too much, a shared schema file plus a test that validates real recorded responses against it catches most of the same problems for an afternoon's work.
Checklist
- Every consumer enumerated, including mocks, fixtures, stored data and clients in the wild
- Local mock or dev server either generated from the source of truth or contract-tested
- Change classified as additive or breaking, honestly
- Breaking changes split into expand / migrate / contract, each shippable alone
- "Nothing reads the old field" proven by telemetry, not assumed
- Compatibility reasoned about for old clients and already-written data, not just the type check
- Additive exhausted before versioning; end date set when a new version launches
- Deprecation instrumented and warned at runtime, not only documented
- A contract test both sides run in CI