🌐 Section 6 · Question #15

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

  1. URI Path Versioning (Industry Standard):
    GET /api/v1/users/42
    GET /api/v2/users/42
    

    Adopted by Stripe, GitHub, Google, AWS, and Kubernetes. The most explicit, debuggable, and cache-friendly approach.

  2. Query Parameter Versioning:
    GET /api/users/42?version=2
    
  3. Custom Request Header Versioning:
    GET /api/users/42 HTTP/1.1
    X-API-Version: 2
    
  4. 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:

  1. Expand: Add first_name and last_name as nullable columns.
  2. Dual-Write: The application writes incoming data to both full_name and the new separate columns.
  3. Backfill & Read: A background migration splits historical data into the new columns. Release API v2 to read exclusively from first_name and last_name.
  4. Contract: Once API v1 is permanently sunset, safely drop the full_name column 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

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):

  1. Canary & Shadow Traffic: Route 5% of production traffic hitting /api/v1/users to a container running v2 (shadow mode), comparing response payloads and latencies before broad rollout.
  2. 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 internal v2 service, transforms the JSON response back into the legacy v1 structure 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 Vary header configuration.
    • L7 reverse proxies (NGINX, Envoy) can easily route /v1 and /v2 to separate Kubernetes deployments.
    • Developers can test endpoints directly in web browsers without specialized HTTP client tooling.

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/42 with X-API-Version: 1. The CDN caches the v1 representation.
  • Client B requests the same /api/users/42 with X-API-Version: 2. The CDN detects a URL match and serves the cached v1 response to Client B!
  • The Resolution: The origin server must supply the Vary: X-API-Version response 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:

  1. Expand: The database schema is expanded with new columns or tables while preserving existing structures.
  2. Dual-Write: The application writes to both old and new schema structures simultaneously.
  3. 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.
  4. Contract: After all clients migrate to v2 and v1 is 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/v1 path.
  • The gateway transforms the request payload into the v2 format, dispatches it to the internal v2 microservice, and maps the v2 response back into the legacy v1 schema.
  • 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:

  1. URI Path (/api/v1/users) — The dominant industry standard (~85% adoption) due to seamless CDN edge caching, simple gateway routing, and browser testability.
  2. Query Parameters (/users?version=2) — Simple, but fragments CDN cache keys.
  3. Custom Headers (X-API-Version: 2) — Keeps URIs clean, but requires Vary headers for caching.
  4. Content Negotiation (Accept: application/vnd.v2+json) — Pure REST approach, but introduces tooling complexity.

Deprecations are standardized via Sunset (RFC 8594) and Deprecation headers. 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 Vary header, leading to cache poisoning across versions).