A deprecation notice tells a consumer that something is changing. It does not tell them what to do about it. That's a separate document, and it needs to answer four questions in order:
- What changed. The specific field, endpoint behavior, or type, named exactly, not "the response format was improved."
- What it looked like before and after. A real before/after pair of the actual payload, not a description of one.
- What action is required, if any. Sometimes the answer is "none, this is additive," and saying so explicitly saves a consumer from doing unnecessary work.
- What the timeline is. The same
deprecatedAt/sunsetAtdates the headers carry, restated in plain language.
// docs/migrations/2026-license-v2.md
## License response: v1 → v2
### What changed
`status` was added to the license response. Nothing was removed or renamed.
### Before (v1)
{ "key": "lic_123", "name": "...", "domains": [...], "created_at": "..." }
### After (v2)
{ "key": "lic_123", "name": "...", "domains": [...], "created_at": "...", "status": "active" }
### Action required
None if you don't read `status`. Read it if you want to stop polling for
expiration separately.
### Timeline
v1 deprecated 2026-09-01. v1 stops responding 2027-01-15.
Write the guide before you write the deprecation notice, not after. If you can't fill in all four sections plainly, the change probably isn't ready to ship yet.
Retiring a version
Retiring a version is two separate decisions dressed up as one: deciding it's safe, and deciding to do it. Don't let the second one happen before the first is actually true.
Safety comes from data, not from the calendar alone. LogApiRequestsMiddleware already logs every request; once ResolveApiVersionMiddleware sets api_version on the request, that value rides along in the same log line for free. Query it before you touch the sunset date: if v1 traffic hasn't dropped to zero (or to a small, known, already-notified list) by the date you picked, push the date, don't ignore it.
Once traffic is actually gone, retiring is deletion, not a feature flag left in place "just in case." Remove the V1 case from ApiVersion, delete LicenseResourceV2's reason for existing by promoting its body into LicenseResource directly, delete the branch in the controller, delete the deprecation-date entries for the case that no longer exists. A version you keep serving after its sunset date isn't cautious, it's a promise you broke in the other direction: you told consumers it would stop, and it didn't, which means the next sunset date you publish means less than the last one.
Before you version anything:
- [X] Know whether the change is additive or breaking before you write it, using the five-question test above
- [X] Resolve the version once, at the edge, in a middleware, not scattered through controllers
Before you deprecate:
- [X] Put a real
deprecatedAtandsunsetAton the version, not a vague future promise - [X] Push at least three notices to registered consumers, not just response headers
Before you retire:
- [X] Confirm from actual request logs that the version's traffic is gone or fully accounted for
- [X] Delete the old code path completely once it's retired, don't leave it as dead weight
A version you never retire isn't stability. It's a debt you're still paying interest on.