Change-Resilient APIs: Breaking Change Rules, Versioning Strategies, Deprecation (RFC 9745) and Sunset (RFC 8594)
The way to change an API without breaking it is to ship additive changes without a version, define response enums as open from the start, and ship a real breaking change as a new version announced with the Deprecation and Sunset headers. At Bookstore we see with real responses how a new order status breaks a strict .NET client, a dated api-version while replacing author with authors, the RFC 9745 and RFC 8594 headers, and the usage log of a deprecated version.
Version note: The code in this post was built and run with C# 14 and .NET 10 (ASP.NET Core 10.0.15); the HTTP examples are real responses from this application, and the OpenAPI 3.2 document was linted with Redocly CLI 2.78.0 and Spectral 6.17.1.
Guess First
Two changes went live this week in Bookstore, the online bookstore used as the shared example across this site's series. Institutional orders got partial shipments, and with them a new order status. The catalog team also added the first book with two authors; a single author string is no longer enough, so an authors array now sits next to it:
$ curl -s http://127.0.0.1:52318/books/9780060853983 | jq -c .
{"isbn":"9780060853983","title":"Good Omens","author":"Terry Pratchett, Neil Gaiman","authors":["Terry Pratchett","Neil Gaiman"],"category":"fantasy","price":{"amount":329.00,"currency":"TRY"},"inStock":true}
$ curl -s -H "Authorization: Bearer linus" "http://127.0.0.1:52318/institutions/lakeside-school/orders?pageSize=1" | jq -c '.items[] | {id, status, createdAt}'
{"id":"01a1d3b8-5e40-7f2a-9c61-3d8e0b47a915","status":"PartiallyShipped","createdAt":"2026-11-13T09:30:00Z"}
The next morning the integration of Nevin Hanım's school can no longer read the order list. Emre has also opened a PR that deletes author, because the Angular screen now uses authors. Three questions: Is the new status value a breaking change? The new authors field? Deleting author, which we believe nobody uses? The answers are spread over the rest of the post, but here is the first hint: all three depend on what the contract says.
What Is a Breaking Change?
A breaking change is a change that makes an existing client, written according to the contract, stop working although the client itself did not change. The "according to the contract" part matters: by Hyrum's Law from the first post there will also be clients that depend on undocumented behavior, but the contract decides where responsibility starts. Google's AIP-180 is honest here: a behavior change that would break reasonably written code counts as incompatible even when the behavior was never documented, and this call takes some judgment. The classification we added to Bookstore's style guide:
| Change | Example | Result |
|---|---|---|
| New field in a response | Adding authors | Additive; safe for a tolerant reader |
| New optional field in a request | An order note | Additive; its default must keep the old behavior |
| New value in an open enum | PartiallyShipped | Additive if the contract says the enum is open |
| Removing or renaming a field | Dropping author | Breaking |
| Changing a type or format | Turning author into an array | Breaking |
| Making an optional request field required, tightening validation | No longer accepting ISBN-10 | Breaking |
| Changing a default | Making the default sort by date | Breaking |
| Changing a status code or problem type | 422 instead of 409 | Breaking |
The default change is the row most easily missed. Raising the default page size from 10 to 20 breaks nobody's JSON, but it changes how a screen looks that shows "the first 10 books" without sending a parameter. Status code changes are of the same kind: as we said in post 04, error codes are part of the contract, and a client may have written if (status == 409).
Tolerant Readers and Open Enums
Half of a breaking change is the server's responsibility and half is the client's. In the tolerant reader approach Martin Fowler described in 2011, a client reads only the fields it needs from a response, ignores fields it does not know, and does not treat unknown values as errors. The Zalando guidelines put it on both sides too: rule #108 asks clients to be written so that they accept compatible extensions, and rule #111 treats an OpenAPI document as open for extension by default. The school's integration, however, was written like this in September:
// The status values the integration knew in September.
enum KnownStatus { Pending, Confirmed, Shipped, Delivered, Cancelled }
enum StatusView { Pending, Confirmed, Shipped, Delivered, Cancelled, Other }
record StrictOrder(Guid Id, KnownStatus Status);
record TolerantOrder(Guid Id, string Status);
static class StatusMapping
{
// Tolerant reader: an unknown value becomes Other instead of an exception.
public static StatusView From(string status) =>
Enum.TryParse(status, ignoreCase: false, out StatusView view) && Enum.IsDefined(view) && view != StatusView.Other
? view
: StatusView.Other;
}
$ dotnet run --project clients/dotnet-reader
strict: The JSON value could not be converted to StrictOrder. Path: $.items[0].status | LineNumber: 0 | BytePositionInLine: 82.
tolerant: 01a1d3b8-5e40-7f2a-9c61-3d8e0b47a915 PartiallyShipped -> Other
tolerant: 01a1c112-d7c0-706c-8cb9-c18a8fadc1a6 Pending -> Pending
tolerant: 01a1a612-9c80-7e46-8590-67e08b9d2434 Confirmed -> Confirmed
JsonStringEnumConverter throws a JsonException on a value it does not know, and one new order makes the whole page unreadable. The tolerant version reads the status as a string and shows an unknown value as "Other". So whose bug is it? In Bookstore's contract, OrderStatus has not been a closed enum since the second post; it is defined with an examples list and the description "new values may be added; clients must accept unknown values". That is also the shape Zalando recommends for open-ended value lists (rule #112). The Azure guidelines do the same with modelAsString: true inside x-ms-enum and say to use a closed enum only when the set of values will never change. AIP-180 allows adding values to enums in responses but asks for this to be documented and for caution. So the answer to the first question: as far as the contract goes, PartiallyShipped is an additive change, but we still have to announce it and find and warn the old integrations. On the request side enums stay closed: the status=PartiallyShipped filter is validated and an unknown value gets a 400. We wrote this split into the style guide as BS-52.
The Additive Path First: the authors Field
The answer to the second question is easy: adding authors is additive. The hard part is what to do with the old field. Turning author into an array is a type change and deleting it removes a field; both are breaking. So the first step is to expand: both fields live side by side, the old one is derived from authors ("Terry Pratchett, Neil Gaiman") and is marked deprecated in the contract:
author:
type: string
deprecated: true
description: >-
Only in api-version 2026-09-01: the authors joined with ", ". Removed in
2026-11-15; use authors.
authors:
type: array
minItems: 1
items:
type: string
This is the first half of expand-contract, described in the Microservices series post "Service Contracts and Versioning: Expand-Contract, Tolerant Reader and Consumer-Driven Contract Tests". The second half, actually deleting the old field, is a separate decision. Often the best option is never to delete: keeping a field in a response costs a few bytes, deleting it costs a broken integration. Here, though, author presents the wrong model for a book with two authors: names joined with a comma break filtering by author or linking to an author page. That is why Emre's PR is a fair request. And the way to ship a fair breaking change without breaking anyone unannounced is versioning.
Versioning Options
There are five common ways to serve several versions of an API at the same time:
| Method | Example | Who uses it | Cost |
|---|---|---|---|
| Major version in the URL | /v2/books | Google (AIP-185) | The same book gets two URLs; a new version changes every link and stored address |
| Query parameter | ?api-version=2026-11-15 | Azure (required, date format) | Must be carried on every request and every link |
| Custom header | X-GitHub-Api-Version: 2022-11-28 | GitHub | Invisible in a browser and in links; caches need Vary |
| Media type | Accept: application/vnd.bookstore.v2+json | Zalando (#114) | A media type per resource; weak tool support |
| No versioning | Additive changes only | Zalando's first choice (#113) | A wrong model is carried forever |
Two questions decide the choice. First, what does a version cover: the whole API or a single resource? URL and dated versions cover the whole API; a media type version covers one representation. Second, what happens when no version is sent? Azure returns a 400 MissingApiVersionParameter, so the client has to pick a version on purpose. GitHub uses version 2022-11-28 when the header is missing and supports a previous version for at least 24 months after a newer one ships. Zalando explicitly forbids versions in the URL (#115), because the URL is the resource's identity and a book's identity should not change with a version.
Bookstore's Decision: a Dated api-version
Bookstore's style guide took the Azure guidelines as its base in the first post, and for versioning we stay close to them, with one difference. The decision we wrote in ADR-0004 is: additive changes get no version, breaking changes ship with a new, date-named api-version, but the parameter is not required. If it is missing, the oldest supported version is used. Making the parameter required would break, on release day, every client that has never sent it. The result:
$ curl -i http://127.0.0.1:52318/books/9780060853983
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Date: Sat, 14 Nov 2026 23:10:45 GMT
Server: Kestrel
Content-Language: en
ETag: "1"
Transfer-Encoding: chunked
Api-Version: 2026-09-01
Deprecation: @1794700800
Sunset: Mon, 17 May 2027 00:00:00 GMT
Link: <https://api.example.com/docs/versions/2026-09-01>; rel="deprecation"; type="text/html"
{"isbn":"9780060853983","title":"Good Omens","author":"Terry Pratchett, Neil Gaiman","authors":["Terry Pratchett","Neil Gaiman"],"category":"fantasy","price":{"amount":329.00,"currency":"TRY"},"inStock":true}
$ curl -i "http://127.0.0.1:52318/books/9780060853983?api-version=2026-11-15"
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Date: Sat, 14 Nov 2026 23:10:45 GMT
Server: Kestrel
Content-Language: en
ETag: "1"
Transfer-Encoding: chunked
Api-Version: 2026-11-15
{"isbn":"9780060853983","title":"Good Omens","authors":["Terry Pratchett","Neil Gaiman"],"category":"fantasy","price":{"amount":329.00,"currency":"TRY"},"inStock":true}
$ curl -s "http://127.0.0.1:52318/books?api-version=2026-12-01" | jq .
{
"type": "https://api.example.com/problems/unsupported-api-version",
"title": "Unsupported api-version",
"status": 400,
"detail": "api-version 2026-12-01 is not supported; see supportedVersions.",
"instance": "/books",
"supportedVersions": [
"2026-09-01",
"2026-11-15"
],
"traceId": "00-54ee528786ed938557d80e80d1bc62be-7d77932004ec2962-00"
}
$ curl -s "http://127.0.0.1:52318/books?api-version=2026-11-15&fields=isbn,author" | jq -c '{status, errors}'
{"status":400,"errors":{"fields":["unknown-field"]}}
$ curl -s -D - -o /dev/null "http://127.0.0.1:52318/books?q=tolkien&pageSize=1&api-version=2026-11-15" | grep -i -E "^(api-version|link)"
Api-Version: 2026-11-15
Link: </books?q=tolkien&pageSize=1&api-version=2026-11-15&pageToken=CfDJ8AAAAAAAAAAAAAAAAAAAAACvPMXZYNlbqzg40EXjtTmrSsfFYXeHBdAsSCvZulg6nuiJ92H20OpZPI1pP0EaQEczBax-5GBD1GL-ZxE10UshltW0DvLOHG1ro9ecdpeHJz6eXOb4i5t7ggc4WBWf5T3CbB__gq8Zda4wryQEWIHmh4_DYBvVEIJDNCJoDnRGRYeyjSw955tT_heUMNLYeis>; rel="next"
Every response says which version produced it in the Api-Version header; support looks at it first when a complaint comes in. The new version has no author, and it cannot be selected in fields either. An unknown version returns a problem that lists the supported versions. Azure behaves similarly: it returns a 400 UnsupportedApiVersionValue for a value it does not recognize and lists the supported versions in the message. The page link carries api-version automatically, because in post 05 we built Link from the current query. The Azure guidelines also ask clients to send the version on every request, including nextLink and operation status URLs.
A dated version has one more advantage over a v2 in the URL: the version number does not raise expectations of a "big rewrite". Version 2026-11-15 differs from 2026-09-01 in a single field, and the server carries two code paths only for the representation that changed. Page tokens and filters are version-independent. One thing does go wrong: clients that never send a version will still break on the old version's sunset date. After that date, requests without the parameter fall to the oldest version still supported, which will be 2026-11-15. The way to prevent that is to find those clients before the sunset.
The Deprecation and Sunset Headers
Deprecation and sunset are two different moments. Deprecation means "stop using this, use that instead" and does not change behavior; RFC 9745, published by the IETF in March 2025, says so explicitly: a deprecated resource keeps working as before. Sunset is the date on which the resource will stop responding and is defined in RFC 8594 from 2019. The two headers use different formats: Deprecation is a Structured Field date, an @ followed by Unix time; @1794700800 means 15 November 2026 00:00 UTC. Sunset uses HTTP's classic date format. RFC 9745 also says the sunset date must not be earlier than the deprecation date. The rel="deprecation" in the Link header points to the migration guide, and rel="sunset", defined by RFC 8594, to the sunset policy.
In Bookstore these headers appear in two places. One is the old version we saw above. According to RFC 9745 the header applies only to the resource of the response that carries it; a wider scope (here, "everything in this api-version") only means something if it is documented, so we wrote it into ADR-0004. The other is the old RPC endpoint that has been frozen since the first post:
$ curl -i -X POST -H "Content-Type: application/json" -d '{"method":"GetBook","params":{"isbn":"9780441172719"}}' http://127.0.0.1:52318/legacy/rpc
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Date: Sat, 14 Nov 2026 23:10:45 GMT
Server: Kestrel
Content-Language: en
Transfer-Encoding: chunked
Deprecation: @1794700800
Sunset: Wed, 31 Mar 2027 23:59:59 GMT
Link: <https://api.example.com/docs/migrations/legacy-rpc>; rel="deprecation"; type="text/html"
Link: <https://api.example.com/docs/sunset-policy>; rel="sunset"; type="text/html"
{"success":true,"data":{"booK_ISBN":"9780441172719","booK_TITLE":"Dune","authoR_NAME":"Frank Herbert","pricE_TL":489.00,"stocK_QTY":14,"supplieR_CODE":"ace-tr"}}
The headers alone are not enough; most clients never look at response headers. That is why Zalando defines deprecation as a process: mark the deprecation in the OpenAPI document (#187), get the consumers' approval before shutting down (#185), monitor usage until the sunset (#188) and add the headers (#189). To monitor usage, Bookstore logs every request to the old version together with who made it. From the application's console:
info: Bookstore.Deprecation[0]
Deprecated api-version 2026-09-01 used by anonymous for GET /books/9780060853983
info: Bookstore.Deprecation[0]
Deprecated api-version 2026-09-01 used by linus for GET /institutions/lakeside-school/orders
info: Bookstore.Deprecation[0]
Deprecated api-version 2026-09-01 used by nevin for GET /orders
This list is the list of institutions Hakan will call before the sunset. We cannot tie anonymous calls to a person; for them what is left is announcements, documentation and, close to the sunset date, short planned outages. Tracking deprecated APIs in an inventory and the security risk of old endpoints are covered in detail in the API Security series post on misconfiguration and inventory. Before RFC 9745, Azure had defined its own azure-deprecating header; in a new API, using the standard header is better because tools recognize it.
This also answers the third question from Guess First: deleting author is breaking, but Emre's PR was not rejected. The field is gone only from the new version; the old version keeps returning author for six more months and says in every response when it will go away.
In ASP.NET Core
The solution in Bookstore is deliberately small: a middleware reads api-version, returns a problem for an unsupported value, puts the selected version into HttpContext.Items and adds the Deprecation, Sunset and Link headers for the old version. The representation difference is one line: author is filled in the old version and null in the new one, and because we do not write null members to JSON (BS-11), the field does not appear in the response. In a real project, the Asp.Versioning library gives you the URL, query, header and media type methods, per-version endpoint mapping and the api-supported-versions response header out of the box; setting it up and using it with controllers is covered in the ASP.NET Core Web API series post "ASP.NET Core Web API Versioning: URL, Query String and Header with Asp.Versioning". Which method to choose, though, is a matter for the decisions in this post, not for the library.
Common Pitfalls
Opening a new version for every change. If you open a version for a new field or a new endpoint, clients have to migrate without gaining anything and the server carries many versions that barely differ. A version is for breaking changes only.
Assuming adding an enum value is safe. If the contract does not say the enum is open, an old client's deserializer may throw on the new value; the JsonException above was exactly that. Write into the contract from the start that response enums are open, and keep request enums closed.
Silently changing a default. The behavior of a request without parameters is contract too. A rule like "no version means the newest" breaks everyone who does not send a version on every release.
Leaving deprecation to the header alone. Few clients read the header. Without usage logs, direct announcements to consumers and a reminder before the sunset date, sunset day turns into an outage.
Not enforcing a sunset date once it has passed. If an endpoint announced to close in six months stays open for two years, nobody takes the next announcement seriously.
A Small Experiment
★☆☆ Without changing the strict reading in the dotnet-reader project, only by changing JsonSerializerOptions, can you read PartiallyShipped? If you can, which C# value does it become?
★★☆ The write side did not change in this post: PUT and PATCH still take a single author string. Design a write contract that takes authors in version 2026-11-15. What should happen to a book with two authors that is written through the old version?
★★★ Design what requests to the old version get once the sunset date arrives: 410 Gone, falling through to the new version, or a problem type? Write your decision and its reasoning as an ADR.
In this post we looked at how a contract changes over time. In the next post we move to changes that arrive at the same time: two people updating the same book (concurrency), Idempotency-Key so that an order resent after a network error is not created twice, and 202 Accepted with a status monitor for work that takes seconds.