Skip to content
Katabench
Try free
8 min read The Katabench team

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. 1. Existing contract

    Old client reads contactName.

    Server: contactName

  2. 2. Expand across the fleet

    Old client keeps working.

    Server: contactName + displayName

  3. 3. Enable new client behavior

    New client reads displayName.

    Old client still reads contactName

Removing contactName while old clients remain breaks their contract.

Adding displayName creates a migration path. Removing the original field needs a separate compatibility decision.

A release is compatible when the clients that still exist can keep doing the work they were already allowed to do.

Separate the contract from the implementation

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:

JSON
{
  "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:

JSON
{
  "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.

Additive does not mean harmless

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.

Give new request fields the old behavior by default

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:

C#
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.

Write down the coexistence matrix

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.

Test preserved behavior with an old consumer

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:

C#
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.

Plan rollback before removing the bridge

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.

Practice making boundaries executable

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

  • 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.

    Open in the editor: Two Sides of the Hexagon

More like this: C# architecture exercises →

Get new puzzles and .NET tips in your inbox

A short note when fresh kata land, plus the C# and performance tricks behind the grading. No spam, unsubscribe anytime.