Your API Needs an Evolution Strategy
Design changes around your clients and their ability to deploy independently

In my experience, teams used to building internal applications sometimes bring an assumption into API design: if an interface changes, we can coordinate with the people consuming it and deploy together. I have seen that assumption create substantial extra work around release planning and rollback. When several deployments depend on each other, a failure in one can leave the system in a combination nobody intended to run. Users can lose access to a service while teams work out how to recover it.
I understand how that habit develops. When the consumer is another application in the same organization, its developers may be one conversation away. A coordinated release can feel like a reasonable shortcut. But public API clients have their own priorities, approval processes, maintenance windows, and users. Their release schedule does not belong to us.
If an API change requires your clients to deploy at the same time as you, treat that as a design warning. Before accepting the coordination cost, ask whether the contract could support a period in which both old and new clients work.
Putting yourself in the client's place changes the design discussion. Can they keep serving their users while they migrate? Can they test the new behavior before switching? Can either side recover from a failed release independently? Those questions turn versioning into an engineering strategy.
Start with the contract a client already depends on
An API contract includes more than paths and JSON fields. Clients depend on validation rules, status codes, authorization requirements, default ordering, pagination, error responses, and the meaning of the values you return.
A response can remain valid JSON while breaking an integration. Changing an amount from euros to cents preserves its numeric type but changes what it means. Replacing a synchronous 201 Created workflow with 202 Accepted asks the client to handle a different lifecycle. Making a search return only the first page when it previously returned all matches can silently lose data from an export.
Compatibility therefore needs several kinds of review: can existing code communicate with the service, interpret the result correctly, and still perform its intended workflow? Google's backwards compatibility guidance distinguishes wire, source, and semantic compatibility; it is a useful framework, even when your API follows a different style.
Before designing a change, take one existing client journey and write it down. For an order search, that could be: authenticate, request a page, deserialize the response, display the orders, request the next page, and recover from an error. Review the change against that sequence, including the code the customer deployed months ago.
Compatibility has a direction
For an existing API version, the provider normally needs to keep accepting previously valid requests and returning responses that existing clients can handle. That does not automatically mean a new client can use new features against an old server. This distinction matters during rolling deployments and rollback.
Consider these changes:
| Proposed change | Effect on an existing client | Safer approach |
|---|---|---|
| Require a new request field | Requests that used to work can fail validation | Make it optional with a documented default that preserves the old behavior, or introduce a new contract |
| Remove or rename a response field | Deserialization or application logic can fail | Retain the field during support, or offer the new representation in a separately selected version |
| Add a response field | Often compatible, but strict decoders or schemas can reject it | Establish and test a policy for unknown response fields |
| Return a new enum value | A generated enum parser or exhaustive branch can fail | Define extensible-enum behavior up front; otherwise gate the value behind an opt-in contract |
| Change ordering, units, or error behavior | A client may parse the response and still behave incorrectly | Treat the behavioral change as part of compatibility review |
For example, adding an optional deliveryInstructions field to order creation can preserve old clients: omission must continue to produce the previous delivery behavior. The new server must support that omission deliberately. Requiring clients to start sending an empty string would still force a migration.
Conversely, a new client that sends deliveryInstructions cannot assume an older server will accept or honor it. Deploy support before enabling its use, and decide what happens if the provider rolls back.
Do not assume that every client ignores unknown response fields. If you publish an SDK, exercise its actual decoder. If your documented response schema prohibits additional properties, adding one can violate the contract you supplied. Define extension rules early and apply them consistently; changing the rules after clients have shipped does not update those clients.
Separate the API version from your deployment version
A backend can have many releases while continuing to serve the same public contract. A database migration, a refactor, and a performance improvement do not each need a new client-facing API version.
Use a new major contract when you need an incompatible change that cannot reasonably be delivered within the existing promises. Compatible additions can remain in the supported version. Google's API versioning guidance describes this separation between a service's evolution and the versions consumers select.
The selection mechanism should be explicit and predictable:
| Mechanism | Example | Operational consideration |
|---|---|---|
| URL path | /v2/orders | Visible in requests, routing, logs, and documentation |
| Query parameter | /orders?api-version=2025-05-15 | Clients, gateways, and caches must preserve the parameter |
| Request header or media type | Accept: application/vnd.example.orders.v2+json | Gateways and representation caches must account for the selecting header |
I would use paths for the examples here because they make the two contracts easy to see. Other approaches work if routing, documentation, observability, and caching agree on the selection. With header-based representation selection, configure the cache key correctly and use the appropriate Vary response header; HTTP semantics explains its role.
Avoid silently moving an existing client to a newer contract because it omitted a version or requested a moving latest alias. Document the default, keep it stable for existing integrations, and reject unsupported explicit versions clearly.
Also distinguish the version fields in an OpenAPI document:
openapi: 3.1.0
info:
title: Orders API
version: 2.0.0
paths:
/v2/orders:
get:
summary: List orders using cursor pagination
responses:
'200':
description: An envelope containing items and the next cursor
This is a shortened document fragment. openapi identifies the specification format; info.version versions the API document. Neither field implements request routing or changes a deployed client's behavior. The /v2/orders path needs an implementation. The OpenAPI Info Object definition makes the distinction explicit. An SDK's package version is another separate lifecycle: document which server contracts it supports.
A small response change that breaks a real client shape
Suppose an existing endpoint supports offset pagination. A request to GET /v1/orders?limit=2&offset=0 returns a JSON array:
[
{ "id": "ord_102", "status": "CONFIRMED" },
{ "id": "ord_101", "status": "PENDING" }
]
A client might contain this perfectly reasonable code:
function orderIdsFromV1(body) {
return body.map(order => order.id);
}
You want to introduce cursor pagination and a place for pagination metadata. The proposed response becomes:
{
"items": [
{ "id": "ord_102", "status": "CONFIRMED" },
{ "id": "ord_101", "status": "PENDING" }
],
"nextCursor": "opaque-continuation-token"
}
The old client now fails at body.map(...). Keeping every order field did not preserve compatibility: the response root changed from an array to an object.
Offer that representation at GET /v2/orders?limit=2, while /v1/orders keeps its existing shape and pagination behavior. A migrated client reads body.items and supplies the returned cursor on its next request. In this example, nextCursor: null means there is no next page; the token shown above is illustrative.
Define the v2 pagination contract precisely: maximum and default page sizes, deterministic ordering with a unique tie-breaker, token opacity and expiry, which filters must remain unchanged, and what concurrent inserts or updates mean for traversal. Cursor pagination does not by itself promise a snapshot of the dataset. Clients need to know whether an export requires a separate snapshot mechanism.
Use version-specific request and response adapters around shared application logic where practical. A new HTTP representation does not require copying the entire service. It does require preserving each supported contract's behavior, including its pagination rules. A v1 client should not start receiving cursor semantics simply because the internal query implementation changed.
Deploy support first, migrate clients gradually
A migration should have valid intermediate states. For the order example, I would plan three provider stages:
- Expand: deploy a release that serves both v1 and v2. Keep v1 working, and make v2 available in a test environment with documentation and examples. Complete the production rollout before inviting clients to depend on v2, or route v2 traffic only to instances that support it.
- Migrate: clients test and switch on their own schedules within the published support window. Observe traffic and failures by API version and authenticated integration identity. Maintain both contracts while the migration is active.
- Retire: remove v1 only after the announced policy and migration criteria have been met. Retirement is a separate operational decision from introducing v2.
The compatibility matrix makes the deployment boundary visible:
| Client behavior | Original server: v1 only | Compatibility release: v1 + v2 | Later release: v1 + v2 | After v1 retirement: v2 only |
|---|---|---|---|---|
| Existing client uses v1 | Works | Must work | Must work | Unsupported |
| Migrated client uses v2 | Unsupported | Must work | Must work | Works |
The second row explains why “we can always roll back to the previous version” is incomplete. Once clients depend on v2, a server that only supports v1 is no longer a safe general rollback target. Keep a known-good release supporting both contracts, test recovery to that baseline, and retain the supporting infrastructure and data shape.
If a new client deliberately supports falling back to v1, test that path. Do not assume fallback exists, and do not silently retry a state-changing request against another version: an ambiguous response may hide an operation that already succeeded. Recovery must preserve the operation's semantics and idempotency rules.
A shared database can impose another rollback boundary. If an API release writes values that the older binary cannot interpret, preserving the HTTP route is insufficient. Keep schema and data changes compatible with the supported rollback baseline, backfill when needed, and defer destructive cleanup until the old readers and rollback window have been retired.
Feature flags help control exposure, but disabling a feature that clients already require can still break them. Once a public capability is in use, recovery needs to respect the contract it established.
Give clients a migration path they can actually follow
A release note saying “v1 is deprecated; use v2” leaves most of the work with the consumer. Publish the request and response differences, concrete before-and-after examples, changed defaults and errors, SDK guidance, the support timeline, and a way to raise migration problems.
For this example, show the actual client edits: read items, stop sending offset, persist the opaque cursor only for the intended traversal, and stop when nextCursor is null. Explain the behavior for an expired cursor. Let clients try this against a representative test environment before switching production traffic.
HTTP headers can make the lifecycle discoverable. Here is a fictional response announcing deprecation on 1 June 2025 and planned retirement on 1 December 2025:
HTTP/1.1 200 OK
Content-Type: application/json
Deprecation: @1748736000
Sunset: Mon, 01 Dec 2025 00:00:00 GMT
Link: <https://api.example.com/docs/migrations/orders-v2>; rel="deprecation"; type="text/html"
Deprecation uses a Structured Field date: @ followed by Unix seconds. It is not a Boolean or an HTTP-date. RFC 9745 defines that signal and the deprecation link relation. Sunset uses an HTTP-date and announces when the resource is expected to become unavailable; it does not prescribe the response after retirement. See RFC 8594. Deprecation and shutdown are separate events; a deprecation notice alone is not a shutdown instruction.
Headers complement communication. They do not guarantee a human has seen the notice. Use the channels clients agreed to receive, and track migration progress. A quiet week does not prove that an integration is unused: monthly jobs, seasonal workloads, and recovery tools may not appear in that window.
Choose a support period that matches your clients' release constraints, then publish it and monitor it. The dates above illustrate the header formats, not a universal six-month policy. Supporting old versions has a cost, so make retirement deliberate rather than either indefinite by accident or abrupt for consumers.
Test the combinations you intend to support
Keep the published contract as a baseline and review schema changes in CI. A structural diff can flag a removed field or a newly required input. It cannot prove that an unchanged numeric field still uses the same units, that authorization scopes remain sufficient, or that ordering and errors retain their meaning.
For the migration above, preserve fixtures and tests that exercise these expectations:
| Check | What it protects |
|---|---|
| Old requests against the new provider | Previously valid inputs and defaults still work |
| Old client decoder against new v1 responses | Existing consumers still understand the representation |
| v2 traversal over multiple pages | The envelope, cursor termination, ordering, and invalid-token behavior match the documented contract |
| Known-good rollback release against current data | Recovery remains possible after new writes and migrations |
| Both versions during the overlap period | A refactor shared by the adapters does not silently break one version |
A minimal JavaScript regression check can reuse the old decoder instead of replacing it with the new one. Assume the test server has a controlled fixture containing the two orders shown earlier:
import assert from 'node:assert/strict';
const response = await fetch(
'http://localhost:8080/v1/orders?limit=2&offset=0'
);
assert.equal(response.status, 200);
const body = await response.json();
assert.ok(Array.isArray(body));
assert.deepEqual(orderIdsFromV1(body), ['ord_102', 'ord_101']);
Here orderIdsFromV1 is the unchanged function shown above. Run the check against the candidate provider with deterministic test data and the authentication required by your API. It catches the envelope change; broader tests must cover validation, error handling, pagination, and semantics. Do not update the old compatibility fixture just to make an incompatible release pass.
Consumer-driven contract testing can add evidence from known integrations. Consumers exercise their real client code and publish expected interactions; the provider verifies those interactions against its implementation. Pact's workflow explains this approach. For a public API, those contracts cover participating consumers, not every client in the world. They support your documented compatibility policy; they do not replace it.
Treat independent deployment as a design outcome
Teams still need to communicate about API changes. The goal is to give that communication a migration window instead of making it a requirement that everyone presses deploy together.
Before releasing a change, ask what happens if a client does nothing, if it upgrades next month, or if your new release has to be rolled back. If the only safe answer is a synchronized deployment, revisit the contract and the intermediate states.
Thinking from the client's side makes those states easier to design. It also makes the API easier to trust: users can keep doing their work while the teams on either side improve the software at their own pace.


