“The new server is deployed” is not a compatibility plan.
Suppose a profile response renames displayName to name. The new
client knows name. The old client knows only displayName. If
server and client deploy together inside one process, the rename is easy. Across a network,
old and new versions meet for a while by default.
That overlap is where queues, retries, caches, rollbacks, and partially refreshed pages live. A provider that removes the old field immediately turns a rollout detail into a user-visible failure. A provider that keeps both fields forever pays for two contracts and never gets to delete the old path.
Compatibility is a temporary state with an owner, an observation window, and an exit condition.
Read the one-release renameTypeScript · old readers meet the new response
export function oldClient(response: ProfileV1): string {
return response.displayName;
}
// Renaming the response in one release makes the old client fail at runtime.
export function breakingServer(row: ProfileRow): ProfileV2 {
return { id: row.id, name: row.name ?? row.displayName ?? '' };
}
export function oldClientAtRuntime(response: unknown): string | null {
if (typeof response !== 'object' || response === null || !('displayName' in response))
return null;
return typeof response.displayName === 'string' ? response.displayName : null;
} The old client does exactly what its contract says: it reads displayName. The
breaking server emits only name, so the old reader gets no value: null in TypeScript, and an empty string when Go decodes the missing field. The
bug is not in the old client; the provider removed a promise while a consumer still depended
on it.
Three shapes trade coordination for overlap.
A big-bang replacement is small when the owner controls every consumer. Expand-contract adds a valid intermediate state and removes it later. Parallel versions make the split explicit when consumers need a long or independently owned migration.
Big-bang
Change the provider and all consumers as one coordinated cutover.
- Good at
- One deployable unit with no stale readers.
- Risk
- Rollback, caches, queues, and hidden clients break the assumption.
Expand-contract
Understand both forms, migrate readers and writers, then remove the old form.
- Good at
- Gradual changes with one eventual contract.
- Risk
- The overlap needs telemetry and a removal date.
Parallel versions
Keep v1 and v2 endpoints or media types side by side.
- Good at
- Long migrations and independently owned clients.
- Risk
- Every version multiplies tests, fixes, and support work.
| Stage | Provider | Consumer | Evidence |
|---|---|---|---|
| Expand | Reads or emits old and new names | Old clients keep working | Old-field traffic is measurable |
| Migrate | Supports the overlap | New clients move to the new name | Errors and fallback usage stay visible |
| Contract | Removes the old name | Only new clients remain | Owner signs off on usage and rollback |
Read the Go migrationGo · pointer fields and separate response versions
type profileRow struct {
ID string
DisplayName *string
Name *string
}
type profileV1 struct {
ID string `json:"id"`
DisplayName string `json:"displayName"`
}
type profileV2 struct {
ID string `json:"id"`
Name string `json:"name"`
}
func expandWrite(row profileRow, displayName string) profileRow {
return profileRow{ID: row.ID, DisplayName: text(displayName), Name: text(displayName)}
}
func readDisplayName(row profileRow) (string, error) {
if row.Name != nil {
return *row.Name, nil
}
if row.DisplayName != nil {
return *row.DisplayName, nil
}
return "", fmt.Errorf("profile %s has no display name", row.ID)
}
func serveV1(row profileRow) (profileV1, error) {
displayName, err := readDisplayName(row)
if err != nil {
return profileV1{}, err
}
return profileV1{ID: row.ID, DisplayName: displayName}, nil
}
func serveV2(row profileRow) (profileV2, error) {
displayName, err := readDisplayName(row)
if err != nil {
return profileV2{}, err
}
return profileV2{ID: row.ID, Name: displayName}, nil
}
func contractOldField(row profileRow) profileRow {
row.DisplayName = nil
return row
} The Go storage row keeps both names optional so the reader can fall back during migration. Separate v1 and v2 response structs prevent a storage field from silently deciding the public contract.
Make the intermediate state valid before you ask anyone to move.
Choose a contract change and a rollout plan. Follow the old client through the provider’s intermediate state, then ask what evidence allows the old path to disappear. The lab calls out when the result is safe now, safe only during a window, breaking, or dependent on a fallback.
Change the migration plan and the contract change.
compatible-window
Old client reads the old field or old value during the overlap.
Provider accepts or emits both representations until consumers migrate.
Expand, observe migration, then contract after the old reader is gone.
Watch for The overlap is not permanent: measure old traffic and set an owner and removal date.
Read the dual-read / dual-write codeTypeScript · both names during the overlap
export function expandWrite(row: ProfileRow, displayName: string): ProfileRow {
return { ...row, displayName, name: displayName };
}
export function readDisplayName(row: ProfileRow): string {
const displayName = row.name ?? row.displayName;
if (!displayName) throw new Error(`profile ${row.id} has no display name`);
return displayName;
}
export function serveV1(row: ProfileRow): ProfileV1 {
return { id: row.id, displayName: readDisplayName(row) };
}
export function serveV2(row: ProfileRow): ProfileV2 {
return { id: row.id, name: readDisplayName(row) };
}
export function contractOldField(row: ProfileRow): ProfileRow {
const next = { ...row };
delete next.displayName;
return next;
} The writer stores both names, and the reader prefers the new name while falling back to the old one. That ordering lets the new path lead without making old data invalid. The final contract deletes the old field only after the new reader works on its own.
Choose what must remain true while versions overlap.
Good migration plans name the old reader, the intermediate state, and the evidence for removal.
A migration is a sequence, not a rename.
Start with inventory: browsers, mobile apps, workers, partner clients, queues, stored records, and rollback paths. Then make the overlap observable. Count old-field reads, fallback branches, unknown enum values, and requests by client version. Those measurements give the removal date meaning.
Keep compatibility logic close to the boundary. The domain should not know that v1 called a
person’s name displayName. An adapter can read both forms and hand the domain
one meaning, so the migration has a clear place to retire.
The one-release rename that breaks an old profile client.
export function oldClient(response: ProfileV1): string {
return response.displayName;
}
// Renaming the response in one release makes the old client fail at runtime.
export function breakingServer(row: ProfileRow): ProfileV2 {
return { id: row.id, name: row.name ?? row.displayName ?? '' };
}
export function oldClientAtRuntime(response: unknown): string | null {
if (typeof response !== 'object' || response === null || !('displayName' in response))
return null;
return typeof response.displayName === 'string' ? response.displayName : null;
} func oldClient(response profileV1) string {
return response.DisplayName
}
// A one-release rename gives the old client an empty field.
func breakingServer(row profileRow) profileV2 {
value := ""
if row.Name != nil {
value = *row.Name
} else if row.DisplayName != nil {
value = *row.DisplayName
}
return profileV2{ID: row.ID, Name: value}
}
// oldClientAtRuntime is a v1 reader: it decodes whatever the server sent into profileV1.
// A missing displayName decodes to the zero value, an empty string.
func oldClientAtRuntime(response any) string {
wire, err := json.Marshal(response)
if err != nil {
return ""
}
var decoded profileV1
if err := json.Unmarshal(wire, &decoded); err != nil {
return ""
}
return decoded.DisplayName
} - Inventory readers and writers
- Classify additive and breaking changes
- Choose fallback and rollback
- Keep the overlap valid
- Measure old-path traffic
- Alert on fallback and unknown cases
- Announce the retirement
- Remove old code and data paths
- Keep a record of the final owner
Cached bundles make rollout order visible.
Build frontends?A cached bundle is an old client you already ship.
Where it already is in your components
A tab left open since yesterday runs yesterday’s bundle against today’s server. A rollback puts last week’s server behind today’s bundle. Your components already live through rollouts in which two versions of the same contract meet.
When you have to own it
When a field your component reads is being renamed, own the overlap on the client side. The textbook components read either version through a small compatibility adapter. The wild components go directly to v2 and assume every server, cache, and browser moved together. That assumption is often the part a production rollout disproves first.
A compatibility adapter accepts v1 or v2 while the provider transition is in flight.
import { useEffect, useState } from 'react';
type ProfileV1 = { id: string; displayName: string };
type ProfileV2 = { id: string; name: string };
type ProfilePayload = { data: ProfileV1 | ProfileV2 };
type ProfileApi = { getProfile(): Promise<ProfilePayload> };
function readDisplayName(payload: ProfilePayload): string {
return 'name' in payload.data ? payload.data.name : payload.data.displayName;
}
export function ProfileCard({ api }: { api: ProfileApi }) {
const [payload, setPayload] = useState<ProfilePayload | null>(null);
useEffect(() => {
let active = true;
void api.getProfile().then((next) => {
if (active) setPayload(next);
});
return () => {
active = false;
};
}, [api]);
if (!payload) return <p>Loading…</p>;
return (
<p>
<strong>{readDisplayName(payload)}</strong>
</p>
);
}
Compatibility has carrying costs.
New enum values
An added value can break exhaustive readers. Give old clients an unknown-case path or coordinate the release.
Fallback can hide debt
Count fallback use. A migration that never measures its old path cannot know when it is finished.
Rollback is another version
The previous server may return after the new writer has stored new data. Test that direction too.
Delete the adapter
Leaving dual-read code forever increases the state space and makes the temporary contract permanent.
Use overlap when the world cannot move atomically.
Use a big-bang change only when you can name every consumer and prove the cutover and rollback are controlled. Use expand-contract when there is one eventual contract and a manageable overlap. Use parallel versions when clients migrate on different schedules or need explicit independent support windows.
The shape matters less than the discipline: name what remains compatible, instrument the old path, announce the end, and make removal a real change with a real owner.
Keep this questionSee where this shows up in your components.
Which old reader could still meet this new writer, and what evidence says it is gone?
Contracts survive by choosing what may change.
When change cannot be atomic, make the in-between state safe enough to live in.
Connections to follow nextRelated lessons
- API contracts names the promise between provider and consumer.
- Serialization hazards shows why the wire representation itself can change meaning.
- Branch by abstraction applies the same overlap discipline to an implementation seam.
- Expand and contract takes it to larger schema and deployment changes.
- Why
- Old clients, cached bundles, and rolled-back servers still meet the new contract.
- What
- Expand with
namebesidedisplayName, readname ?? displayNameduring the overlap, then contract to name only. - Constraint
- Every reader that could meet the new writer must stay valid until it is gone.
- Fallback
- Unknown values take a safe unknown-case path, and its use is counted.
- Reconsider when
- Measured old-path traffic reaches zero and an owner signs off the final contract step.