Software Engineering · Refactoring

Expand, Migrate, Contract: Changing an Interface Without a Flag Day

When callers cannot be changed in one deploy, the change is split into three phases that are each independently safe: add the new shape, move every user, remove the old.

A change to a published interface has an ordering problem that a change to internal code does not. During a rolling deploy, old and new versions of a service run at the same time. If the new version stops accepting the old request shape at the moment it starts emitting the new one, there is an interval in which each version rejects the other’s traffic. The interval is short, the failure is total, and it happens at every deploy.

The standard resolution is to split the change into three phases whose safety does not depend on deploy timing. Expand adds the new form while keeping the old. Migrate moves every producer and consumer onto the new form. Contract removes the old form once nothing uses it. Each phase is a complete, deployable state, and the interface is valid in all three.

What makes a phase safe

A phase is safe when every participant can run any mix of old and new code without error. That is a stronger condition than “the new version works,” and it is the condition that forces the phases apart.

During expand, the server accepts both shapes and the client sends one. A client that has not been upgraded sends the old shape; the server handles it. A client that has been upgraded sends the new shape; the server handles that too. Both work, so the deploy order does not matter.

During migrate, all clients send the new shape and the server still accepts both. This is where the old shape’s usage is verified to have reached zero. Without this phase, contract is a guess.

During contract, the server stops accepting the old shape. This is safe only because the previous phase established that nothing sends it, and the establishment is the part that is easy to skip.

// Expand: field 1 stays, field 5 is added. Both are accepted.
message CreateOrder {
  string customer_id = 1;       // legacy spelling, still read
  int64  quantity     = 2;
  string sku          = 3;
  // new: structured line items, added alongside the flat fields
  // LineItem is declared in the same file.
  repeated LineItem items = 5;
}

Field numbering in a binary protocol makes expand cheap: adding a field is backward compatible because an old reader ignores an unknown field number and a new reader treats an absent field as default. The cost is that the message now has two representations of the same information, and every reader must decide which one to honor when both are present. That decision must be specified, not left to each implementation.

The verification problem in the migrate phase

Knowing that nothing sends the old shape requires instrumentation on the interface itself, not on the code. Three sources of evidence, none sufficient alone.

Server-side counters on the old path. A counter that has been at zero for a full deploy cycle, including a weekly batch job, is reasonable evidence. A counter at zero for an hour is not, because the callers include things that run on schedules.

Client-side inventory. Every repository that builds a client is enumerable if the client is internal. The inventory is only accurate if it includes clients that were built from an old revision and never rebuilt, which is why a version header on requests is worth more than a repository search.

Contract tests that assert the old shape is rejected. These are useful in the contract phase and dangerous in the migrate phase, because a failing contract test during migrate means a client is still on the old shape, which is exactly the signal wanted, but only if the test runs against real traffic rather than a fixture.

The failure mode of an early contract phase is a caller that is invisible to all three sources: a mobile client in an app store review queue, a partner integration, a script in a cron directory on a machine nobody inventories. Interface changes that reach outside the organization have a minimum migrate duration set by the slowest consumer’s upgrade cadence, and that number is not knowable from inside.

Deprecation as a phase, not an announcement

Between migrate and contract there is usually a period in which the old shape still works and is known to be obsolete. This period is where most of the operational risk lives, and it is worth treating as its own phase with its own instrumentation.

The useful instruments are per-caller usage attribution and a deadline. Attribution means the server can say which client identity used the old shape, so the conversation about migrating is with a specific owner rather than broadcast. The deadline means the contract phase has a date, so the migrate phase has a schedule rather than an intention.

A deprecation with neither is a state that persists indefinitely. The old shape remains accepted, the code that supports it remains, and the cost is paid on every subsequent change to the interface: every new feature must be implemented for both shapes or must explicitly decline to support the old one, and that decision recurs forever.

The cost of an unremoved old path is the same double-correctness obligation described in Branch by Abstraction and the Cost of Two Live Paths, applied to a boundary that also has external consumers. It is larger, because the external consumers cannot be inspected.

Ordering when data is involved

An interface change that also changes stored data has two orderings to reconcile, and getting it wrong produces data that neither version can read.

The rule is that the data format must be expanded before any code that writes the new format runs, and contracted only after no code reads the old format. Code and data phases are not symmetric: code can be rolled back in seconds, data cannot be rolled back at all without a backup restore.

That asymmetry gives the ordering: expand the schema and the reader first, deploy code that writes both, verify, deploy code that reads only the new, verify, then contract the schema. Writing both formats is the dual-write pattern, and its consistency problem is the subject of Migrating Stored Data While Both Formats Are Live.

For a binary protocol the equivalent constraint is that an old reader must be able to parse a message written by a new writer. That holds if new fields are optional and unknown fields are preserved or ignored, and it fails if a field’s meaning changes in place rather than a new field being added. Reusing a field number with a different meaning is the one change that cannot be expanded, because there is no way for a reader to distinguish the two meanings.

Rollback at each phase

A phase is only as safe as its rollback. The three phases have very different rollback properties, and the difference determines how much verification each needs before proceeding.

Expand rolls back trivially. Reverting the deploy removes the new field from the writer and leaves data that the old code ignores. Nothing is lost.

Migrate rolls back badly. Clients have been changed; reverting the server does not revert them. If the server stops accepting the new shape after clients have moved, every client fails. This is why the migrate phase must keep accepting both shapes, and why the decision to stop accepting the old one belongs to a separate deploy.

Contract does not roll back. Once the old shape is rejected and the supporting code deleted, restoring it is a new change with its own expand phase. Contract is therefore the phase that needs the most evidence before it starts and the least verification after.

The version-number alternative

A versioned interface, where the old and new shapes live at different paths or different protocol versions, is sometimes presented as an alternative to expand-contract. It is not. Versioning moves the problem: the server now maintains two full implementations rather than one implementation with two accepted shapes, and the old version still has to be retired eventually, which requires the same evidence that nothing uses it.

Versioning is the right choice when the two shapes are semantically different rather than structurally different, because then a single implementation cannot honestly serve both. When the change is a rename, a restructure, or an added optional field, expand-contract produces one implementation and a temporary acceptance path, which is less to maintain for the duration.

The general principle is that every compatibility mechanism is a debt with an interest rate. Expand-contract has the lowest rate because the compatibility surface is small and its removal is a compiler-checked deletion. Full versioning has the highest, because each version is a complete surface that must be tested, patched, and eventually retired. Choosing the mechanism is choosing how long the debt will be carried and what it costs per period.

References

  1. Refactoring Databases: Evolutionary Database Design — Ambler and Sadalage, Addison-Wesley, 2006
  2. Site Reliability Engineering — Beyer, Jones, Petoff, Murphy, O'Reilly, 2016
  3. Semantic Versioning 2.0.0 — Tom Preston-Werner