← Concepts & practices
Pattern Boundaries and contracts

Versioning & compatibility

Give change a safe window.

An API contract is never read by only the process you just deployed. Old browsers keep cached bundles, workers finish queued messages, mobile clients update slowly, and a rollback can bring a previous server back. A change is compatible when those versions can coexist safely—not simply when the new code works on your machine.

The judgment to keep

Prefer additive changes. When a change is breaking, create an overlap in which old and new consumers are both valid, measure the old path, announce its retirement, and remove it only when someone owns the evidence and the final cutover.

TypeScriptGo One profile rename · three rollout shapes
Start with time

“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
rollout.ts · breaking rename
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.

Name the rollout

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.

Choice 01

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.
Choice 02

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.
Choice 03

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.
How the profile rename moves
StageProviderConsumerEvidence
ExpandReads or emits old and new namesOld clients keep workingOld-field traffic is measurable
MigrateSupports the overlapNew clients move to the new nameErrors and fallback usage stay visible
ContractRemoves the old nameOnly new clients remainOwner signs off on usage and rollback
Read the Go migrationGo · pointer fields and separate response versions
rollout.go · versions and expand-contract
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.

Follow the change

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.

Profile rollout

Change the migration plan and the contract change.

Runs a local rollout model
Expand → migrate → contractRename displayName to name
01Expand the provider to understand both shapes
02Dual-write or dual-read during the migration window
03Roll consumers forward and watch old traffic
04Contract only after evidence says the old shape is unused
Compatibility

compatible-window

Old client

Old client reads the old field or old value during the overlap.

New server

Provider accepts or emits both representations until consumers migrate.

Window

Expand, observe migration, then contract after the old reader is gone.

Expand-contract buys compatibility time by making the intermediate state valid.

Watch for The overlap is not permanent: measure old traffic and set an owner and removal date.

The controls change a local model; they do not deploy versions or inspect production traffic.
Read the dual-read / dual-write codeTypeScript · both names during the overlap
rollout.ts · expand-contract
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.

Practice the rollout

Choose what must remain true while versions overlap.

Good migration plans name the old reader, the intermediate state, and the evidence for removal.

The provider wants to rename displayName to name while old clients are still running.
A new enum value is added to a response. What must the old client have?
When should the old version be removed?
What does expand-contract require from the provider?
Feedback stays on this page; it is not saved.
Put it in production

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.

TypeScriptReading
rollout.ts
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;
}
GoAlongside
rollout.go
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
}
Before
  • Inventory readers and writers
  • Classify additive and breaking changes
  • Choose fallback and rollback
During
  • Keep the overlap valid
  • Measure old-path traffic
  • Alert on fallback and unknown cases
After
  • Announce the retirement
  • Remove old code and data paths
  • Keep a record of the final owner
Recognize it in UI code

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.

ReactAlready in your code
textbook.tsx · compatibility adapter
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>
	);
}
Keep the window finite

Compatibility has carrying costs.

01

New enum values

An added value can break exhaustive readers. Give old clients an unknown-case path or coordinate the release.

02

Fallback can hide debt

Count fallback use. A migration that never measures its old path cannot know when it is finished.

03

Rollback is another version

The previous server may return after the new writer has stored new data. Test that direction too.

04

Delete the adapter

Leaving dual-read code forever increases the state space and makes the temporary contract permanent.

Make the call

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?

Take the idea with you

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
Why
Old clients, cached bundles, and rolled-back servers still meet the new contract.
What
Expand with name beside displayName, read name ?? displayName during 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.