in

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.

REST API version lifecycle timeline: v1 sunset, v2 active, with deprecation and sunset dates

"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

/api/v2/orders

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

API-Version: 2

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

Accept: application/vnd.acme.v2+json

Most REST-compliant; versioning per representation

Complex; client libraries struggle

Hypermedia-heavy APIs

Date-based

API-Version: 2026-12-19

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

?version=2

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

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

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

  3. Announcement: Changelog, e-mail, portal notice, the Deprecation header; direct contact with the remaining consumers (API key → contact).

  4. 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).

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

  1. A new version for every change: An API at v7 has six versions in maintenance; with additive evolution it would still be at v1.

  2. Independent versions per endpoint: "Orders v3, customers v1" — the consumer can't tell which combination works.

  3. Silently adding enum values: Strict clients crash; a warning in the contract and client preparation.

  4. Changing a default: Same schema, different behavior; the hardest break to find.

  5. Leaking the version into the domain model: if (version == 1) in a service; version differences belong in the DTO layer.

  6. Deprecation without a sunset: "It will shut down some day" moves no consumer; give a date.

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