MigrationGENERALLIFETIME-SPECIFICCONTESTED

Expand and Contract

Add the new shape, write both, migrate readers, stop writing the old, remove it. The canonical safe sequence, and the reason it works is that every step is individually revertible.

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

What is the general sequence for replacing one representation with another while everything keeps running?

The requirement

A Money value currently stored as a float must become an integer number of minor units plus a currency code, because float arithmetic is producing off-by-one-cent errors in reconciliation.

The obvious build

Change the type, migrate the data, update the consumers, ship it. Five files and a migration; expand-and-contract is enterprise ceremony for a two-line change.

Why it breaks

The rolling deploy alone breaks it: for several minutes, instances with the old code read rows written by instances with the new one, and a float field holding an integer count of cents is silently wrong by a factor of a hundred.

How it breaks as requirements change
  • The rolling deploy alone breaks it: for several minutes, instances with the old code read rows written by instances with the new one, and a float field holding an integer count of cents is silently wrong by a factor of a hundred.
  • The event payload is consumed by systems that deploy on their own schedule, so the change is not atomic no matter how atomic the commit is.
  • The cache holds old-shaped objects that outlive the deploy, which is a consumer nobody listed (Cache Invalidation, Stampedes and Hot Keys in Backend).
  • Rollback is impossible after the first new-shaped row is written, so the two-week window is theoretical from minute one.
  • As soon as a second change to Money arrives mid-migration, there is no defined state to make it against (Incremental Migration).
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 representation appears in the database, in an event payload, in a public API response and in a cached object.
  • Deploys are rolling, so two code versions are live simultaneously for several minutes.
  • A rollback window of two weeks is required by the change-management policy.
  • The team cannot coordinate every consumer to deploy at the same moment; one of them is a mobile client (Backward Compatibility as a Constraint).
Invariants
  • At every step, both the old and new code must be able to operate on the data that exists. That is the property that makes each step revertible.
  • The two representations must never disagree while both are authoritative for anything. One is the source; the other is derived.
  • Contraction is the only irreversible step, and it must be reached with evidence rather than on a schedule (Designing the Migration).

Who owns what, and where the seams fall

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

Responsibilities
  • One representation is authoritative at any moment; the other is derived from it. The step boundaries are exactly the points where authority moves (State Ownership).
  • The write path owns keeping the derived representation in sync — ideally by deriving it at a single point rather than by two independent writes (Duplicate Knowledge).
  • Someone owns each transition's guard: what evidence permits moving to the next step.
  • Someone owns contraction, on a date, with a verification that no reader remains. Unowned contraction does not happen (Deprecation).
Boundaries
  • Each step is a deploy boundary. Two steps in one deploy removes a revert point and is the most common way the sequence is weakened while appearing to be followed.
  • The overlap boundary — how long both shapes must be maintained — is set by the slowest consumer, not by the fastest one or by the team's patience.
  • Deriving rather than dual-writing keeps the sync boundary at one place; two independent writers is where the representations drift apart.

The sequence, and why each step is where it is

Every step exists to keep the next one revertible. That is the single organising idea, and it explains the ordering completely: readers tolerate before writers produce, data exists before readers depend on it, and nothing is deleted until nothing reads it.

The steps that get skipped are two and five — writing both, and stopping the old write as a separate deploy — and both are skipped for the same reason: while the migration is going well they look like they are not doing anything.

Float amount to minor units plus currency
  1. 1
    1. Expand

    Add amount_minor and currency alongside amount. Nothing reads them. No behaviour changes.

    fails by Adding them as NOT NULL, which is a table rewrite and a lock rather than a metadata change.

  2. 2
    2. Write both

    Every write populates the new fields, derived from the same source as the old one, at a single point.

    fails by Two independent write paths that drift, or missing a writer — the batch job, the admin tool, the trigger.

  3. 3
    3. Backfill

    Populate the new shape for existing rows, in batches, and validate against the old (Data Migration).

    fails by Migrating readers before the backfill finishes, so old rows read as zero.

  4. 4
    4. Migrate readers

    Move consumers to the new shape one at a time, verifying each. The old shape is still written, so each move reverts independently.

    fails by Moving all readers in one deploy, which converts many small reverts into one large one.

  5. 5
    5. Stop writing the old shape

    Separate deploy, taken only when telemetry shows no reads of the old shape for the full rollback window.

    fails by Bundling it with step 4, which closes the rollback window without anyone deciding to.

  6. 6
    6. Contract

    Remove amount from the schema, the payload and the type. Separate deploy, separate approval.

    fails by Never happening, which is the normal outcome and leaves the system permanently carrying both shapes.

Six steps, six deploys, and the system is correct and revertible after each. Step 5 is the point of no return and deserves to be a decision rather than a consequence (Reversible and Irreversible Decisions).

Derive, do not dual-write

The phrase "write both" invites the wrong implementation: two assignments, side by side, that a later change will update one of. Deriving one representation from the other at a single point makes drift structurally impossible rather than merely discouraged.

This is the same principle as having one owner for a piece of knowledge, applied to a representation that must temporarily exist twice (Duplicate Knowledge).

Two ways to keep both shapes populated during the overlap
Two independent writes
function saveOrder(o: Order) {
  row.amount       = o.total.asFloat()      // old shape
  row.amount_minor = o.total.minorUnits()   // new shape
  row.currency     = o.total.currency
  db.save(row)
}

// six weeks later, a rounding fix lands in asFloat() only.
// the two fields now disagree for new orders, and nothing notices
// until reconciliation, which reads whichever one it was migrated to.
One source, one derivation point
// the new shape is the source; the old is derived, once.
function toRow(m: Money) {
  return {
    amount_minor: m.minorUnits,
    currency:     m.currency,
    amount:       m.minorUnits / 100,   // legacy mirror, delete at step 6
  }
}

// a rounding fix can only land in Money. Both fields move together
// because there is only one place they can move from.

The second version makes drift impossible rather than unlikely, and it localises the deletion at contraction to a single line. It also names the direction of the relationship — new is authoritative, old is a mirror — which is the fact a reader most needs during the overlap and which the first version leaves ambiguous. Note the mirror is marked with the step that removes it, so contraction is a search rather than an archaeology (Docs Close to Code).

The step that does not happen

Contraction is unglamorous, carries the only irreversible risk in the sequence, and delivers no visible value. It is therefore the step that gets deferred, and the deferral is how organisations end up with codebases where every representation exists twice.

The countermeasure is structural rather than cultural: schedule contraction when you schedule expansion, put the date in the code, and treat an overdue contraction the way you would treat any other overdue obligation.

A second change to Money arrives — support a currency with three decimal places
The change

Add support for currencies with three minor-unit digits (KWD, BHD), which the fixed division by 100 cannot express.

Contraction never happened — both shapes still live, six months later
MoneyOrderRowEventPayloadPublicApiResponseCacheSerializerReconciliationJobLegacyMirror
testsmoney_testorder_row_testevent_payload_testapi_contract_v1api_contract_v2reconciliation_test
7 modules · 6 test files

The legacy float mirror cannot represent three decimal places at all, so the change requires deciding what the mirror does for KWD — and every consumer still reading it must be found and asked. The migration that was "finished" is now blocking the next change.

Contraction executed on schedule six weeks after the read switch
MoneyOrderRow
testsmoney_testorder_row_test
2 modules · 2 test files

One representation, one place the exponent lives. The change is an edit to Money and a schema column for the exponent.

what it cost Reaching the after state required a deploy that delivered no feature, plus the work of proving from telemetry that nothing still read the float — perhaps two days, spent at a moment when the migration already felt done and the team had moved on. That is precisely why it does not happen, and why the date belongs in the code at expansion time rather than in someone's intention (Interest: Why Debt Compounds).

How to build it

Most important first.

  • Expand. Add the new shape alongside the old. Nothing reads it yet. This step changes no behaviour and is trivially revertible.
  • Write both. Every write populates both, ideally by deriving one from the other at a single point. Ship this before anything reads the new shape, so that a reader deployed later never meets a row without it.
  • Backfill. Populate the new shape for existing data, in batches, with validation. Now both shapes are complete (Data Migration).
  • Migrate readers. Move consumers to the new shape one at a time, verifying each. The old shape is still written, so any reader can be reverted independently.
  • Stop writing the old shape. A separate deploy, taken only when no reader remains. This is the step that closes the rollback window, so it should be a deliberate decision with a date.
  • Contract. Remove the old shape from the schema, the payload, the type. Separate deploy, separate approval, because it is the irreversible one.
  • Never skip "write both". It is what makes every earlier step revertible, and it is the step that looks redundant when the migration is going well.

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
  • This change: five or six deploys over two to three weeks, plus derivation code, plus a backfill. Against perhaps a day for the naive version.
  • Every step is individually cheap and individually revertible, which is the actual product being bought: the cost of being wrong at any point is one revert rather than an incident.
  • The next representation change is substantially cheaper, because the team has the sequence, the tooling and — crucially — the habit of shipping "write both" as its own release (The Rule of Three).
  • What stays expensive: any change where the two representations cannot coexist, such as a change in the *meaning* of a field rather than its shape. The sequence has no answer for that and neither does anything else (Backward Compatibility as a Constraint).
What the recommended approach costs
  • Six deploys for a two-line change is a real cost, and for a representation confined to one deployable with no persisted data it is straightforwardly the wrong process.
  • The overlap period has genuinely worse code than either end state: two shapes, derivation, and conditional reads that a newcomer cannot interpret without knowing a migration is in progress.
  • It is slower to the benefit. The float bug persists until readers are migrated, which is weeks after the fix was written — and if the bug is causing active harm, that delay has to be weighed against the safety.

What can go wrong

Failure modes
  • Steps are combined "to save a deploy", and the sequence retains its name while losing the property it existed for.
  • The two representations are written independently and drift; a later bug fix updates one and not the other, and now the system has two answers (Invariant Leaks).
  • Contraction never happens. The codebase permanently carries both shapes, every new engineer asks which one is real, and the answer requires archaeology.
  • The backfill completes but is not validated, so readers are migrated onto a shape that is complete and wrong.
  • The mitigation fails on its own terms: "write both" is implemented, but one write path — a batch job, an admin tool, a database trigger — was missed, so a subset of rows has only the old shape and the migration of readers breaks for exactly those.
Dependencies, and their direction
  • Each step depends on the previous step having reached every instance and every consumer, which is a deployment property rather than a code property (Version Coexistence: N and N+1, in Both Directions in DevOps).
  • The sequence depends on being able to represent both shapes simultaneously — a nullable column, an optional field, a second event attribute. Where the medium cannot express that, the sequence needs a different mechanism (Versioned Interfaces).
  • Contraction depends on an accurate reader inventory, which depends on telemetry rather than on code search.
Misreads
  • "Expand and contract means add a column and drop a column." Those are the first and last steps. The sequence is the three in the middle, and skipping them is the failure it exists to prevent.
  • "We can contract as soon as the readers are migrated." Not until the rollback window has closed. Contracting immediately means a revert of the reader migration has nothing to revert to.
  • "Dual-write is the mechanism." Dual-*write* invites drift. Derive one representation from the other at a single point wherever the medium allows it; two independent writers is a consistency problem you created (Duplicate Knowledge).
  • "This is a database technique." It applies to any representation with independently-deployed readers: an event field, an API response, a cache key, a file format (Expand, Migrate, Contract in DevOps names the same sequence at the delivery layer).

Testing it, and how it ages

What to test, and at which boundary
  • At the "write both" step, assert derivation rather than equality of two independent writes: a test that the new shape is a pure function of the old one is what prevents drift.
  • Run the previous release's suite against data written by the current one, at every step. That is the check that each step is genuinely revertible.
  • Before contraction, assert from production telemetry that the old shape has had no reads for the full rollback window.
  • Test the intermediate states explicitly — old-only, both, new-only — because each is a real configuration the system runs in (State Machines).
How this design ages
  • The sequence generalises well beyond databases: it is the same for an API field, a message attribute, a config key, a directory layout and a function signature. Teams that recognise the shape stop redesigning it per change.
  • The derivation code written at "write both" is often worth keeping as the definition of the relationship between the shapes, at least until contraction.
  • Long-running expand-and-contract sequences degrade into permanent dual representation unless contraction is scheduled at the moment expansion begins (Revisit Triggers).

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.

  • GENERALThe sequence follows from needing every intermediate state to be simultaneously readable by old and new code, so it holds for schemas, payloads, config, file layouts and function signatures alike.
  • LIFETIME-SPECIFICInside one deployable with no persisted data and no external consumers, the whole sequence collapses to a rename with compiler support, and following it is ceremony. It becomes mandatory the moment a representation is written down somewhere that outlives a deploy.
  • CONTESTEDThe strongest opposing view: the middle steps have a real defect rate of their own — dual representations drift, conditional readers hide bugs, and a fleet running in the overlap state is a configuration nobody tested thoroughly. Teams that have had a drift incident argue for a short coordinated break with a rehearsed rollback instead, on the grounds that a five-minute window of known risk beats three weeks of subtle risk. That case is strong when consumers can be coordinated, and it collapses entirely when one of them is a shipped mobile client.

Where the depth lives

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

Domains that do not exist yet
  • Testing & Reliability Engineering — running the previous release's test suite against current data is what makes "every step is revertible" a checked claim rather than an assertion.