REST API Versioning and Backward Compatibility: A Guide
'We added a field and the mobile app crashed.' An API is a contract, and you don't control its consumers. Which changes are breaking, the tolerant-reader principle, URI, header and date-based versioning strategies, the Spring Boot implementation, retiring versions with Deprecation and Sunset headers, contract tests and breaking-change detection in CI.
"We added a field to the response and the dealer's integration stopped." "We added an enum value and the mobile app crashed." "We corrected a field name and three customers called the same day." An API is a contract, and you don't control the other party — mobile apps, dealers, partners, internal teams stuck on an old version. That's why version management isn't a debate about URL formats but a compatibility discipline: knowing what you can change without breaking anyone, and giving consumers time and a path when you must.
This article covers breaking and non-breaking changes, the tolerant-reader principle, URI, header and date-based versioning strategies, the Spring Boot implementation, retiring versions with Deprecation and Sunset headers, contract tests and breaking-change detection in CI.
Compatibility first: what breaks, what doesn't
Change | Breaking? | Note |
|---|---|---|
Adding a new (optional) field to a response | No* | *If the consumer ignores unknown fields (tolerant reader) |
Adding a new optional request parameter | No | The default behavior must not change |
Adding a new endpoint | No | |
Removing / renaming a field | Yes | Add the new name, keep both for a while, then retire the old one |
Changing a field's type/format (string → number, date format) | Yes | Add as a new field |
Adding a new enum value | Usually yes | Strict clients crash on unknown values; the contract must say "new values may appear" and clients must be prepared |
Adding a required request field | Yes | Add as optional with a default |
Tightening validation (length, format) | Yes | A request that passed yesterday and gets 400 today is a break |
Changing error codes / status codes | Yes | Clients branch on the code |
Changing default behavior (sorting, page size) | Yes | The sneakiest break: same schema, different behavior |
Changing the URL structure or authentication | Yes | A new major version |
Two principles govern this table. Tolerant reader: the consumer ignores fields it doesn't know and treats unknown enum values with a sensible default; the provider writes this into the contract. On a Spring consumer, spring.jackson.deserialization.fail-on-unknown-properties=false (client side) and @JsonEnumDefaultValue for enums. Additive evolution: add instead of removing or renaming; what's to be removed is first marked "deprecated" and retired with a date. With these two principles most APIs live for years on a single major version.
Versioning strategies
Strategy | Example | Pro | Con | Fit |
|---|---|---|---|---|
URI path |
| Visible, cacheable, easy gateway routing, testable in a browser | Bends the "URL is the resource identity" principle; the whole API's version moves together | Public/partner APIs; the default choice |
Custom header |
| Clean URLs; per-endpoint versioning possible | Invisible; needs cache and gateway configuration; hard to test from a browser | Internal APIs, fast-evolving services |
Content negotiation |
| Most REST-compliant; versioning per representation | Complex; client libraries struggle | Hypermedia-heavy APIs |
Date-based |
| Continuous small changes; the client pins its date (the Stripe model) | Transformation layers pile up on the server; needs serious infrastructure | Platform APIs with many consumers, evolving continuously |
Query parameter |
| Simple | Easily forgotten, ends up in cache keys, messy | Not recommended |
The practical recommendation: for a public API, major versions only in the URI (v1, v2); no minor versions — non-breaking changes are added without changing the version number. A new major version is the exception, and even once a year is a lot; every major version is the cost of keeping two versions alive at the same time.
Implementation with Spring Boot
Spring Framework 7 (Spring Boot 4) brought API versioning into the framework: the version is part of request mapping and is resolved from a path segment, a header or a query parameter.
@Configuration
public class ApiVersionConfig implements WebMvcConfigurer {
@Override
public void configureApiVersioning(ApiVersionConfigurer c) {
c.useRequestHeader("API-Version") // or usePathSegment(1) → /api/v2/...
.addSupportedVersions("1", "2")
.setDefaultVersion("1") // no header → the oldest supported version: old clients don't break
.setVersionRequired(false);
}
}
@RestController
@RequestMapping("/api/orders")
public class OrderController {
@GetMapping(path = "/{id}", version = "1")
OrderV1 getV1(@PathVariable long id) { return OrderV1.from(service.get(id)); } // old shape: customerName as a plain string
@GetMapping(path = "/{id}", version = "2+") // 2 and above
OrderV2 getV2(@PathVariable long id) { return OrderV2.from(service.get(id)); } // new shape: a customer object, currency separate
}On Spring Boot 3 the same arrangement is built with a controller package per version (api.v1, api.v2) and a path prefix; only the mapping mechanism differs. In both cases the rule is the same: version differences live in the DTO and controller layer, not in the domain model. OrderV1 and OrderV2 are produced from the same domain object; the business rule lives in one place. Version-based behavior differences (for example page size 20 in v1, 50 in v2) are kept in this layer too.
Retirement headers
@Component
public class LegacyVersionHeaders implements HandlerInterceptor {
@Override
public void postHandle(HttpServletRequest req, HttpServletResponse res, Object h, ModelAndView mv) {
if ("1".equals(req.getHeader("API-Version")) || req.getRequestURI().startsWith("/api/v1/")) {
res.setHeader("Deprecation", "@1767225600"); // RFC 9745: deprecated since 2026-01-01
res.setHeader("Sunset", "Tue, 30 Jun 2026 00:00:00 GMT"); // RFC 8594: shuts down on this date
res.setHeader("Link", "<https://developer.acme.com/api/v2/migration>; rel=\"deprecation\"; type=\"text/html\"");
}
}
}The headers are a machine-readable warning; client libraries and API gateways log them and raise alerts. After the sunset date the old version returns 410 Gone with a link to the migration guide, instead of being switched off silently.
The version lifecycle
A written support policy: "At most two major versions at a time (N and N−1); the old version shuts down at least 12 months after the new one is published." This sentence lives on the developer portal.
Usage metrics per version and per consumer: Which API key calls which version, how much? The shutdown decision is made from this dashboard; that's how "we thought nobody used it" is prevented.
Announcement: Changelog, e-mail, portal notice, the Deprecation header; direct contact with the remaining consumers (API key → contact).
A migration guide: A field-mapping table, example requests/responses, common errors; where possible a temporary compatibility layer (a gateway filter translating v1 requests into v2).
Shutdown: 410 on the sunset date; the path is removed entirely some time later.
Contracts and tests: catching the break before production
OpenAPI as the single source of truth: Generated from code (springdoc) or written first and applied to code; every version's schema in the repository.
Breaking-change detection in CI: The new schema is compared with the previous version's OpenAPI (tools such as oasdiff); field removal, type changes and new required fields break the build. "Breaking it by accident" ends with this step.
Consumer-driven contract tests: With Pact or Spring Cloud Contract, the consumer says "I use these fields in this way"; the provider runs those contracts on every release. The most effective method between internal teams.
Recorded-response tests: "Golden" responses for the old version; byte-for-byte identical after a refactor?
Internal versus external APIs
With consumers you control (teams in the same company), keep the number of versions low: tolerant readers + additive evolution + contract tests often make a single version enough; a breaking change is coordinated with the consumer's deployment. With external consumers (dealers, partners, mobile app-store approval), full discipline: major versions, policy, headers, metrics, guides. Mobile apps matter especially: an old version stays in the field for months; the API may need to carry even N−2 for a while.
Common mistakes in the field
A new version for every change: An API at v7 has six versions in maintenance; with additive evolution it would still be at v1.
Independent versions per endpoint: "Orders v3, customers v1" — the consumer can't tell which combination works.
Silently adding enum values: Strict clients crash; a warning in the contract and client preparation.
Changing a default: Same schema, different behavior; the hardest break to find.
Leaking the version into the domain model:
if (version == 1)in a service; version differences belong in the DTO layer.Deprecation without a sunset: "It will shut down some day" moves no consumer; give a date.
Shutting down without usage metrics: A phone call the same day from the dealer using the version you shut down.
Frequently asked questions
How are versions handled in GraphQL and gRPC?
GraphQL makes versionless evolution a design principle: add a field, mark the old one @deprecated, remove it based on usage metrics — the additive evolution described here, built into the language. In gRPC/Protobuf, field numbers are preserved, removed numbers are marked reserved, and the major version travels in the package name (acme.order.v2).
Does semantic versioning (1.4.2) apply to an API?
For the client library (SDK), yes; for the HTTP API itself, only the major version is meaningful. Minor and patch versions live in the changelog, not in the URL.
What does an API gateway add to version management?
Routing (v1 → the old deployment, v2 → the new), usage metrics, central injection of Deprecation headers, simple transformation (v1 → v2 field mapping) and returning 410 at shutdown; most of the lifecycle can be managed at the gateway without touching the application.
Conclusion
Good API version management means living with few versions: avoiding breaking changes through tolerant readers and additive evolution; when unavoidable, a major version in the URI with the version difference kept in the DTO layer; giving consumers a path with Deprecation and Sunset headers, a written policy, usage metrics and a migration guide; catching breaks in CI with OpenAPI comparison and contract tests. The version number is a tool; respect for the contract is the principle.
To design your APIs, set up your versioning policy or add breaking-change detection to CI, see our software design and architecture and integration and automation services.