API Backward Compatibility During a Rolling Deployment
Preserve API backward compatibility while old clients and servers coexist. Design additive contracts, safe defaults, and tests for staged HTTP rollouts.
The API deployment succeeds. The new web application loads. A customer returns to a tab opened before lunch, clicks Save, and gets a validation error. The frontend now sends a field that the backend requires, but the customer's tab still runs yesterday's JavaScript.
That tab is not an edge case you can solve by deploying faster. Mobile applications, integrations, background jobs, and browser caches all have independent lifetimes. During a rolling deployment, the backend itself may run more than one version. API backward compatibility means previously supported clients can continue their supported operations after the service changes.
The practical question is not whether the latest client works with the latest server. It is which combinations can exist, what each combination sends, and whether the meaning survives.
Expand the server before exposing the client feature
-
1. Existing contract
Old client reads contactName.
Server: contactName
-
2. Expand across the fleet
Old client keeps working.
Server: contactName + displayName
-
3. Enable new client behavior
New client reads displayName.
Old client still reads contactName
Removing contactName while old clients remain breaks their contract.
A release is compatible when the clients that still exist can keep doing the work they were already allowed to do.
An HTTP contract includes more than a JSON schema. Clients depend on field meanings, status codes, pagination behavior, and whether retrying an operation repeats its effect. Renaming a private class is an implementation change. Renaming its serialized property is a contract change if clients see that property.
Suppose an account endpoint returns this representation:
{
"id": "account-42",
"contactName": "Morgan Lee"
}The product now needs a display name independent of the billing contact. Replacing contactName
with displayName breaks the old client immediately. Reusing contactName to mean the display
name is subtler: deserialization succeeds while the billing screen shows the wrong person.
Keep the old meaning and add the new field:
{
"id": "account-42",
"contactName": "Morgan Lee",
"displayName": "Northwind Workshop"
}This is an additive response change, provided the supported clients tolerate unknown properties. That condition deserves a test. Microsoft's API design guidance describes maintaining compatible changes within an existing API and using versioning when changes break the contract. Microsoft: Web API versioning
The database can evolve separately. Perhaps the server introduces a new display-name column and backfills it while continuing to serve the original contact field. An API response should not automatically mirror whichever database columns happen to exist this week.
A response property is usually easier to add than a required request property, but neither change is universally safe. An old consumer may reject unknown JSON members, validate against a closed schema, or hash the entire serialized response. A larger payload can also cross a documented size limit even if every property is optional.
System.Text.Json skips unmapped JSON members by default, but applications can configure it to
reject them. Test the actual consumer configuration rather than assuming the library default is
your compatibility policy.
Microsoft: Unmapped JSON members
Enum-like fields need particular care. Adding "paused" to a status field looks additive in the
schema, yet an old exhaustive switch might throw or map it to an unsafe fallback. Adding a numeric
enum value can fail for the same reason. Decide whether the contract defines an open set of values
and what an unknown value means before publishing one.
Nullability changes also have direction. A server that starts returning null for a previously non-null string can break an old client. A server that starts requiring a previously optional request field can reject an old request. Keep the producer and consumer visible when reviewing the change; the word "optional" on its own is ambiguous.
Assume account updates historically did not send notifications. The new client offers an explicit checkbox for notifying the billing contact. The request model can add an optional flag whose absence preserves that behavior:
public sealed record UpdateAccountRequest(
string ContactName,
bool NotifyContact = false);
public static bool ShouldNotify(UpdateAccountRequest request)
=> request.NotifyContact;The endpoint must still validate ContactName. The important contract decision is that an old
request without notifyContact remains accepted and does not acquire a new side effect. Setting
the default to true would be syntactically compatible but behaviorally surprising.
Now consider the reverse pairing. A new client sends notifyContact: true to an old server that
ignores unknown members. The save succeeds, but no notification happens. Ignoring a field is not
equivalent to supporting the capability.
For this feature, deploy server support across the fleet before exposing the checkbox. Keep the client feature disabled until the supporting server release is established. If routing can still reach an older region, or rollback can restore an older server, the rollout policy must account for that too. Capability negotiation can help, but its answer must remain valid for the server that handles the eventual write.
There are four basic version pairings. Only one is exercised by a typical end-to-end test that builds everything from the current branch.
| Client | Server | Required behavior for this rollout |
|---|---|---|
| Old | Old | Existing account edits work |
| Old | Expanded | Existing edits and response parsing still work |
| New, feature disabled | Old | Existing workflow works during preparation |
| New, feature enabled | Expanded | Display name and notification option work |
The table deliberately excludes enabling notifications against the old server. That pairing is not supported; deployment order and feature exposure must prevent it. Naming an unsupported combination is more useful than quietly assuming it never occurs.
An initial server release can read both old and new requests and return both response fields. A later client release consumes the new field, with a fallback where necessary while older servers are still reachable. Only then does the feature become visible.
Removing the old field is a separate decision. For public integrations, an apparently quiet field
may still be in use by a monthly job. Use a documented support policy and migration process. A
new version can make the break explicit, but publishing /v2 does not migrate consumers or remove
the cost of operating /v1.
Keep representative old request fixtures and deserialize new responses using the old client's model and options. This small test captures one claim about the expanded account response:
public sealed record LegacyAccount(string Id, string ContactName);
[Fact]
public void ExpandedResponsePreservesTheBillingContact()
{
const string response = """
{
"id": "account-42",
"contactName": "Morgan Lee",
"displayName": "Northwind Workshop"
}
""";
var options = new JsonSerializerOptions(JsonSerializerDefaults.Web);
var account = JsonSerializer.Deserialize<LegacyAccount>(response, options);
Assert.NotNull(account);
Assert.Equal("Morgan Lee", account.ContactName);
}In the service's contract suite, obtain the response from the actual endpoint instead of maintaining only a hand-written fixture. Otherwise production serialization can change while the fixture keeps passing. If an independently released client SDK exists, run its supported version against the candidate server as well.
Send an old update payload without the notification flag and assert that the stored contact changes without creating a notification. Then send the new explicit flag and assert the intended effect. Verify relevant error responses too: replacing a validation response with an unhandled server error is a compatibility failure even when the success schema is unchanged.
Schema comparison helps catch deleted properties and changed types. It cannot prove that a field still means billing contact, or that a retry still creates one logical operation. Those claims need behavioral tests. The idempotency design guide examines the retry contract in more detail.
Rollback restores code, not history. Once a new client sends a new request or a new server writes new data, an old binary may encounter it. Ask whether that binary can read the data and whether the client can keep working while the feature is disabled.
For the account example, retaining the original field and its storage gives the old workflow a path through rollback. Changing the old column's meaning or deleting it in the same release removes that path. Backfill and verify new storage first; retire old storage only after its readers and writers are gone under the support policy.
Compatibility work is therefore a sequence with observable exit conditions. The expanded server is deployed. Supported old clients pass contract tests. The new behavior is enabled. Consumers migrate. Only then is removal considered. A calendar date alone does not demonstrate any of those conditions.
A useful exercise is to take a working endpoint, add one feature, and keep the previous client's test suite running throughout the change. Require the tests to assert meaning, not just successful JSON parsing. That turns "should be compatible" into a claim you can inspect.
Katabench's Architecture track develops the related habit of changing internal structure while preserving behavior and respecting explicit boundaries. The grading guide explains how behavior and design rules are checked. Apply the same discipline at an HTTP boundary: identify the promise, keep a consumer that depends on it, and make the safe rollout order part of the design.
Practice Architecture & Design
- Open in the editor: Two Sides of the Hexagon
Architecture Medium Free, no account needed
Two Sides of the Hexagon
A driving (web) adapter reaches the driven (persistence) adapter directly for a quick existence check. Inject the repository "via its interface" and you'll find the seam is still on the wrong side of the hexagon.
More like this: C# architecture exercises →