How to organize REST API versioning
4. Content Negotiation / Vendor MIME Type Versioning:
🟢 Junior Level
REST API Versioning is the set of architectural practices and protocol mechanisms used to manage contract evolution over time, ensuring that changes to data schemas and business logic do not break existing clients (mobile apps, single-page applications, and third-party integrations).
Breaking vs. Non-Breaking Changes
┌────────────────────────────────────────────────────────┬────────────────────────────────────────────────────────┐
│ ⚠️ Breaking Changes │ ✅ Non-Breaking Changes │
│ (Requires Version Bump) │ (Safe to Deploy in Place) │
├────────────────────────────────────────────────────────┼────────────────────────────────────────────────────────┤
│ • Removing or renaming existing JSON attributes │ • Adding new optional response fields │
│ • Changing field data types (`Integer` ──> `String`) │ • Adding new optional query parameters │
│ • Adding new mandatory request payload fields │ • Adding brand-new endpoints (`/api/v1/promotions`) │
│ • Changing HTTP status codes (`200 OK` ──> `404`) │ • Performance optimizations & internal bug fixes │
└────────────────────────────────────────────────────────┴────────────────────────────────────────────────────────┘
[!TIP] Clients adhering to the Tolerant Reader pattern configure JSON deserializers (such as Jackson’s
DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES = false) to ignore unrecognized fields, allowing backend services to deploy non-breaking additions without version increments.
The 4 Foundational Versioning Strategies
- URI Path Versioning (Industry Standard):
GET /api/v1/users/42 GET /api/v2/users/42Adopted by Stripe, GitHub, Google, AWS, and Kubernetes. The most explicit, debuggable, and cache-friendly approach.
- Query Parameter Versioning:
GET /api/users/42?version=2 - Custom Request Header Versioning:
GET /api/users/42 HTTP/1.1 X-API-Version: 2 - Content Negotiation / Vendor MIME Type Versioning:
GET /api/users/42 HTTP/1.1 Accept: application/vnd.company.v2+json
🟡 Middle Level
Comparative Matrix of Versioning Strategies
| Architectural Criterion | URI Path (/v1/users) |
Custom Header (X-API-Version: 2) |
Content Negotiation (Accept: vnd.v2+json) |
|---|---|---|---|
| CDN & Edge Caching | 🟢 Trivial & Flawless (distinct URLs) | 🟡 Complex (Requires Vary: X-API-Version) |
🟡 Complex (Requires Vary: Accept) |
| REST Philosophical Purity | 🔴 Violates (different URIs for same entity) | 🟡 Neutral | 🟢 Pure REST (Fielding’s vision) |
| Browser & Swagger Usability | 🟢 Direct clickable URLs | 🔴 Requires custom header injection | 🔴 Requires specialized client tools |
| Routing at API Gateway | 🟢 Trivial L7 path routing | 🟡 Requires header inspection rules | 🔴 MIME type parsing overhead |
| Production Market Share | ~85% of enterprise APIs | ~10% of enterprise APIs | ~5% of enterprise APIs |
The Deprecation Lifecycle: RFC 8594 (Sunset & Deprecation Headers)
When deprecating an older API version, services must inform consumers programmatically via standardized HTTP response headers:
HTTP/1.1 200 OK
Content-Type: application/json
Deprecation: @1767225600
Sunset: Wed, 31 Dec 2026 23:59:59 GMT
Link: <https://api.example.com/docs/v1-to-v2-migration>; rel="deprecation"; type="text/html"
{"id": 42, "name": "Legacy Payload"}
Deprecation(IETF Draft): Declares that the resource or API version has been deprecated as of the specified UNIX timestamp.Sunset(RFC 8594): Defines the exact timestamp (in HTTP-date GMT format) after which the endpoint will be permanently deactivated.Link: Provides a navigable hyperlink to developer documentation detailing the migration guide.
Zero-Downtime Database Evolution: The Expand and Contract Pattern
API version bumps often fail due to premature database schema changes. To decouple database migrations from API releases, enterprise teams use the Expand and Contract (Parallel Run) pattern:
[Phase 1: Expand] ────► [Phase 2: Dual-Write] ────► [Phase 3: Migrate & Read] ────► [Phase 4: Contract]
Add new nullable Application writes to v2 API reads new columns; Drop legacy column
columns to database both old and new columns v1 traffic is sunsetted safely without downtime
Example: Renaming fullName to firstName and lastName:
- Expand: Add
first_nameandlast_nameas nullable columns. - Dual-Write: The application writes incoming data to both
full_nameand the new separate columns. - Backfill & Read: A background migration splits historical data into the new columns. Release API
v2to read exclusively fromfirst_nameandlast_name. - Contract: Once API
v1is permanently sunset, safely drop thefull_namecolumn from PostgreSQL without taking the database offline.
🔴 Senior Level
Consumer-Driven Contract Testing (Pact / Spring Cloud Contract)
In large-scale microservice meshes, maintaining dozens of service versions leads to a combinatorial explosion of legacy endpoints. Consumer-Driven Contract Testing prevents version churn:
[Consumer (Frontend / Orders)] ──> Defines Pact Contract JSON ("I require id and email")
│
▼
[Pact Broker Storage]
│
[Provider (User Service CI)] ◄────────────────┘
Runs contract validation against every Pull Request.
If a PR removes 'email', the provider build fails before reaching production!
Production Implementation in Spring Boot 3
1. URI Path Versioning (Recommended)
package com.example.controller;
import com.example.dto.UserDtoV1;
import com.example.dto.UserDtoV2;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/v1/users")
public class UserV1Controller {
@GetMapping("/{id}")
public UserDtoV1 getUserV1(@PathVariable Long id) {
return new UserDtoV1(id, "Alice Smith");
}
}
@RestController
@RequestMapping("/api/v2/users")
public class UserV2Controller {
@GetMapping("/{id}")
public UserDtoV2 getUserV2(@PathVariable Long id) {
return new UserDtoV2(id, "Alice", "Smith", "alice@example.com");
}
}
2. Content Negotiation Versioning (Vendor Media Type)
package com.example.controller;
import com.example.dto.UserDtoV1;
import com.example.dto.UserDtoV2;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/users")
public class UserContentNegotiationController {
// Matches: Accept: application/vnd.company.app-v1+json
@GetMapping(value = "/{id}", produces = "application/vnd.company.app-v1+json")
public ResponseEntity<UserDtoV1> getUserV1(@PathVariable Long id) {
return ResponseEntity.ok(new UserDtoV1(id, "Alice Smith"));
}
// Matches: Accept: application/vnd.company.app-v2+json
@GetMapping(value = "/{id}", produces = "application/vnd.company.app-v2+json")
public ResponseEntity<UserDtoV2> getUserV2(@PathVariable Long id) {
return ResponseEntity.ok(new UserDtoV2(id, "Alice", "Smith", "alice@example.com"));
}
}
API Gateway Routing, Canary Deployments, and Legacy Adapters
Senior architects handle versioning routing at the system perimeter using an API Gateway (Spring Cloud Gateway, Envoy, Kong):
- Canary & Shadow Traffic:
Route 5% of production traffic hitting
/api/v1/usersto a container runningv2(shadow mode), comparing response payloads and latencies before broad rollout. - Compatibility Adapter (BFF Layer):
When mobile clients cannot be forced to update and internal services have already decommissioned
v1, the API Gateway intercepts/api/v1/users, invokes the internalv2service, transforms the JSON response back into the legacyv1structure on the fly, and returns it to the client. This keeps core microservice codebases clean.
4 Tricky Questions
1. Why does URI Path versioning formally violate Roy Fielding’s REST principles yet remain the dominant industry standard?
Answer:
Under pure REST theory, a URI must represent a domain entity, not its interface version. User 42 is a single conceptual resource; exposing /v1/users/42 and /v2/users/42 creates two separate URIs for the identical resource, which violates URI permanence and uniform interface constraints.
- However, pragmatism won over purity:
- URI paths are natively cached by standard CDN edge servers without complex
Varyheader configuration. - L7 reverse proxies (NGINX, Envoy) can easily route
/v1and/v2to separate Kubernetes deployments. - Developers can test endpoints directly in web browsers without specialized HTTP client tooling.
- URI paths are natively cached by standard CDN edge servers without complex
2. What critical caching hazard occurs when using header-based versioning (X-API-Version), and how is it resolved?
Answer: If an intermediate proxy or CDN (Cloudflare, Fastly) caches responses purely by URL:
- Client A requests
/api/users/42withX-API-Version: 1. The CDN caches thev1representation. - Client B requests the same
/api/users/42withX-API-Version: 2. The CDN detects a URL match and serves the cachedv1response to Client B! - The Resolution: The origin server must supply the
Vary: X-API-Versionresponse header. This instructs intermediary caches to segregate cache entries using both the URL and the custom header value.
3. What is the Expand and Contract pattern, and how does it prevent database downtime during API version transitions?
Answer: The Expand and Contract pattern decomposes breaking schema migrations into non-breaking, incremental phases:
- Expand: The database schema is expanded with new columns or tables while preserving existing structures.
- Dual-Write: The application writes to both old and new schema structures simultaneously.
- Read Switch: Once historical data is backfilled, the new API version (
v2) reads from the new schema while the legacy API (v1) reads from the old. - Contract: After all clients migrate to
v2andv1is sunset, the legacy database columns are safely dropped with zero downtime.
4. What architectural strategy should you use when legacy mobile apps cannot be updated but the backend must retire v1?
Answer: Implement a Compatibility Adapter at the API Gateway or Backend-For-Frontend (BFF) layer:
- The gateway accepts requests at the legacy
/api/v1path. - The gateway transforms the request payload into the
v2format, dispatches it to the internalv2microservice, and maps thev2response back into the legacyv1schema. - This isolates legacy compatibility logic at the network boundary and allows core backend microservices to decommission legacy code cleanly.
🎯 Interview Cheat Sheet
30-Second Elevator Pitch
There are 4 primary strategies for REST API versioning:
- URI Path (
/api/v1/users) — The dominant industry standard (~85% adoption) due to seamless CDN edge caching, simple gateway routing, and browser testability.- Query Parameters (
/users?version=2) — Simple, but fragments CDN cache keys.- Custom Headers (
X-API-Version: 2) — Keeps URIs clean, but requiresVaryheaders for caching.- Content Negotiation (
Accept: application/vnd.v2+json) — Pure REST approach, but introduces tooling complexity.Deprecations are standardized via
Sunset(RFC 8594) andDeprecationheaders. Database evolutions rely on the Expand and Contract pattern, and contract safety across microservices is verified via Consumer-Driven Contract Testing (Pact).
Red Flags (What NOT to Say)
- ❌ “Adding a new optional field to a response body is a breaking change.” (Tolerant readers ignore new fields; it is backward-compatible).
- ❌ “URI versioning is an amateur mistake that no serious tech company uses.” (Stripe, GitHub, AWS, Google, and Kubernetes all use URI path versioning).
- ❌ “When releasing v2, you can immediately drop old database columns.” (Instant drops cause immediate downtime for running v1 instances; use Expand and Contract).
- ❌ “Header-based versioning caches out of the box on CDNs without extra configuration.” (Fails without the
Varyheader, leading to cache poisoning across versions).
Related Topics
- What is RESTful API design — API architecture
- What is Content Type header — Media types
- What is Accept header — Content negotiation mechanics
- How to properly name REST endpoints — Resource naming conventions
- What is REST — The 6 architectural constraints