How to Migrate an API Contract Without Breaking Existing Clients
A three-phase sequence, expand then migrate then contract, for changing an API contract while old clients keep working, with rollback called out as its own case.
A migration of a contract is a change that some clients cannot absorb in a single release. The safe shape of that change is old and new at the same time, for as long as an old client still calls. This note follows the parallel-change pattern, also called expand and contract, as Danilo Sato described it: expand the interface, migrate clients, then contract by removing the old interface. Thoughtworks later described the same sequence for APIs specifically, as adding a new element and removing the old one only after consumers have switched. Neither source gives a number of days the overlap must last. The duration is a decision you record, not a constant in the pattern.
Decide whether you need a migration
Not every change needs two versions. Changes that can ship inside the current version covers additions that old clients can ignore, and the cases that only look additive. If the change is compatible under the rule you published, ship it inside the current major version and stop. A parallel run for a compatible addition adds a second schema nobody had to learn.
If the change removes or renames a field, narrows a type, adds a required request field, or changes a behavior old clients rely on, it is not compatible inside the current version under AIP-180’s guidance. Then you either publish a new major version and choose the version signal before the move, or you expand in place and keep the old behavior until clients leave. Both are migrations. A silent replacement on the only URL is not.
The migration sequence
Sato’s three phases, applied to an HTTP contract:
- Expand. Add the new field, media type, path, or event type. Keep the old one working, including its previous behavior. Old requests must still succeed. This phase is deployable on its own. AIP-180’s rule that a newly accepted client field must default to the old behavior belongs in this phase, or the expand is already a break.
- Migrate. Move consumers one by one. This is the long phase when the consumers are outside your release train. Publish the date or version after which the old element can disappear, and a way for a consumer to show that it has moved.
- Contract. Remove the old element only after the consumers you agreed to support have moved. Sato notes that deprecation notes help, and that failing to finish this phase leaves you worse than you started, because two versions have to be understood forever.
The illustration is the sequence, not a real order API.
Expand response grows: itemName remains, itemLabel is added
Migrate consumers read itemLabel; itemName still populated
Contract itemName removed, or left only on the old major version
Illustration of the three phases. Field names are generic and are not a captured payload.
During expand and migrate, the server keeps populating the old field. AIP-180 says a field the server previously populated must continue to be populated, even if that is redundant. Stopping early, because the new field “has the same data,” breaks the client you have not migrated.
Adapters at the boundary
When the system behind the API already stores the new shape, an adapter at the edge can still emit the old field. That adapter is part of the contract until the contract phase deletes it. Put it where the HTTP response is built, not scattered through every reader. An adapter that only some code paths use will expand the contract for one client and break another. This placement advice is editorial. The pattern only requires that the old interface keep working. It does not specify a module layout.
Map external schemas the same way. If a partner sends a document that is not your schema, translate it in one place and validate both sides: their document against the schema you think they publish, and your internal message against yours. The field rules on each side of the mapping are the checklist for both documents. Do not “fix” their field names in three consumers.
Evidence that a client has moved
Prose is a weak signal. Contract tests as evidence that a consumer has moved can show that the consumer’s expected response uses the new field and that the provider still returns the old one while the overlap lasts. When the consumer’s interaction no longer mentions the old field, that consumer is a candidate for the contract phase. It is not proof that every consumer has moved. A consumer with no contract test is invisible to that check. The migration plan needs a list of consumers you intend to support, including the ones that will only be confirmed by a call or by traffic you are allowed to inspect. This note does not invent a telemetry requirement. If you cannot see a consumer, you cannot claim they have moved.
The error model during the move should stay stable unless the error change is itself the migration. A client that has adopted the new success body and then receives a renamed error extension has been migrated twice. Expand the error document with the new member, keep the old member, and remove it in the same contract phase as the rest, or in a phase you have written down separately.
Rollback
Rollback is the expand phase run in reverse, and only while the old path still exists.
- After expand, a rollback of the new code is possible if the old field was never stopped. Deploy the previous server. Old clients are unaffected. New clients that already require the new field are not. Do not start the migrate phase by deleting their ability to use the old field.
- After some clients have migrated, a rollback of the server to a build that does not emit the new field breaks those clients. You need the new field to stay, which means the rollback build is not the original one. Keep a build that still emits both, and roll back to that.
- After contract, there is no old field to restore without a new expand. Rolling back by putting the field back is a new change, not an undo, if any client was told the field was gone.
Write the rollback target before the expand ships. “We will revert the commit” is only true in the first phase.
A migration checklist
- The change is classified: compatible inside the current version, or a migration.
- The version signal and the compatibility rule are already published.
- Expand adds the new element and does not change the result of an old request.
- The old element stays populated or accepted through the migrate phase.
- Consumers you support are identified. A contract test covers those who have one. The others have a stated way to confirm the move.
- The contract date is chosen when the expand starts, not after everyone has forgotten the old field.
- The error body is either untouched or given its own expand-migrate-contract note.
- The rollback build for the migrate phase still speaks both shapes.
- Contract removes the old element from the document and the server together, and the removal is a versioned change if any supported client could still send or read it.
What the pattern does not promise
Parallel change does not remove the need for a major version when you cannot run both shapes. It does not make an incompatible change compatible. It spreads the break so that each release is deployable, which is the property Sato highlights for continuous delivery. If you cannot run both shapes, say so, publish the new version, and keep the old version on its old signal until the migrate phase ends. Removing it on the day the new version is announced is a break with a changelog attached.
Sources
- Parallel Change — Martin Fowler
- AIP-180, Backwards compatibility — Google
- API expand-contract, Technology Radar — Thoughtworks
- Introduction to Pact — Pact