Skip to main content

API Versioning Strategies

Version with Intention

API versioning is a contract management strategy. Version when you must break backward compatibility, not arbitrarily. Good versioning balances stability for existing clients with the ability to evolve your API.

Overview

API versioning allows you to evolve your API over time without breaking existing integrations. Once an API is consumed by external clients - whether public partners, mobile apps, or frontend applications - changes to its contract can cause integration failures, data loss, or broken user experiences.

This guide covers:

  • When and why to version APIs
  • Versioning strategies and trade-offs
  • Breaking vs. non-breaking changes
  • Backward compatibility techniques
  • Deprecation and sunset processes
  • Migration paths for API consumers
  • Semantic versioning for APIs

Key principle: Versioning adds complexity. Maintain backward compatibility whenever possible. Version only when breaking changes are unavoidable or when the benefit of a cleaner API justifies the migration cost.


Core Principles

  • Stability First: Existing clients must continue working without modification
  • Explicit Contracts: API versions represent distinct, documented contracts
  • Graceful Evolution: Provide migration paths and deprecation timelines
  • Minimize Versions: Support as few versions as necessary (typically N and N-1)
  • Clear Communication: Document changes and notify consumers proactively
  • Design for Change: Build APIs that can evolve without breaking changes

When to Version Your API

Breaking Changes (Require New Version)

Breaking changes modify the API contract in ways that can cause existing clients to fail. These changes mandate a new version:

Request Contract Changes:

  • Removing a field from request body: Clients sending this field will receive unexpected errors
  • Renaming a field: customerIduserId breaks existing clients
  • Changing field type: amount: stringamount: number causes deserialization failures
  • Adding required field: Existing requests without this field will fail validation
  • Stricter validation: Reducing max length from 500 to 100 characters rejects previously valid data
  • Removing an endpoint: Clients calling deleted endpoints receive 404 errors

Response Contract Changes:

  • Removing a field from response: Clients depending on this field break
  • Renaming a field: Client parsers expecting old name fail
  • Changing field type: Client deserialization fails
  • Changing field semantics: status: "active"status: "ACTIVE" (case change) breaks string comparisons
  • Modifying collection structure: items: []data: { items: [] } changes response shape

Behavioral Changes:

  • Changing authentication mechanism: OAuth 2.0 → API key requires client reconfiguration
  • Modifying business logic: Different calculation results or state transitions
  • URL structure changes: /api/payments/{id}/api/transactions/{id} breaks hardcoded URLs
  • Status code changes: Returning 422 instead of 400 affects client error handling

Non-Breaking Changes (Safe to Deploy)

These changes extend or improve the API without breaking existing clients. They don't require a new version:

Additive Changes:

  • Adding optional fields to requests: Existing clients ignore them; new clients use them
  • Adding new fields to responses: Clients should ignore unknown fields (Postel's Law: "Be conservative in what you send, liberal in what you accept")
  • Adding new endpoints: Doesn't affect existing endpoints
  • Adding new enum values (with caution): Only if clients handle unknown values gracefully
  • Adding optional query parameters: Existing requests work without them

Relaxing Changes:

  • Loosening validation: Increasing max length from 100 to 500 characters accepts more data
  • Making required fields optional: Accepting requests without the field
  • Adding default values: Providing sensible defaults for missing fields

Internal Improvements:

  • Performance optimizations: Faster response times
  • Bug fixes that don't change API behavior
  • Improved error messages: More helpful messages with same status codes
  • Internal refactoring: Changes invisible to API consumers

Important caveat: Even "safe" changes can break poorly written clients. For example, adding a response field might break clients that serialize JSON to a struct with strict deserialization (no extra fields allowed). Design clients to be resilient to additive changes.

Semantic Versioning for APIs

Semantic versioning (SemVer) uses a three-part version number: MAJOR.MINOR.PATCH

  • MAJOR (v1, v2, v3): Breaking changes requiring client updates
  • MINOR (v1.1, v1.2): New features, backward compatible
  • PATCH (v1.1.1, v1.1.2): Bug fixes, backward compatible

For public APIs with OpenAPI specs, apply SemVer to your API version number in the specification. For example:

openapi: 3.0.3
info:
title: Payment Service API
version: 2.1.0 # MAJOR.MINOR.PATCH

MAJOR version (2.0.0): Changed response structure from flat to nested MINOR version (2.1.0): Added new optional metadata field to requests PATCH version (2.1.1): Fixed calculation bug in payment total

However, for URL versioning (e.g., /api/v1/, /api/v2/), most APIs use only the major version number in the URL to avoid path proliferation. Communicate minor/patch updates through API documentation and changelogs without changing URLs. See OpenAPI Specifications for managing version documentation.


Versioning Strategies

Four primary versioning strategies exist, each with distinct trade-offs. Choose based on your API's visibility, client sophistication, and infrastructure capabilities.

Embed the version number directly in the URL path. This is the most explicit and widely adopted approach.

GET /api/v1/payments/PAY-123
GET /api/v2/payments/PAY-123
GET /api/v3/payments/PAY-123

Implementation:

// Version 1: Original schema
@RestController
@RequestMapping("/api/v1/payments")
public class PaymentV1Controller implements PaymentsV1Api {

@GetMapping("/{id}")
public ResponseEntity<PaymentDtoV1> getPayment(@PathVariable String id) {
Payment payment = paymentService.getPayment(id);
return ResponseEntity.ok(PaymentDtoV1.from(payment));
}
}

// Version 2: Breaking changes (restructured response)
@RestController
@RequestMapping("/api/v2/payments")
public class PaymentV2Controller implements PaymentsV2Api {

@GetMapping("/{id}")
public ResponseEntity<PaymentDtoV2> getPayment(@PathVariable String id) {
Payment payment = paymentService.getPayment(id);
// V2 uses different DTO with nested structure
return ResponseEntity.ok(PaymentDtoV2.from(payment));
}
}

Pros:

  • Maximum visibility: Version is immediately obvious in URLs, logs, documentation, browser address bar
  • Simple routing: API gateways, load balancers, and proxies route based on URL paths
  • Cacheable: Different versions are different URLs, no cache confusion or collisions
  • Tooling friendly: Works with all HTTP clients, browsers, and testing tools without special configuration
  • Industry standard: Most public APIs (Stripe, GitHub, Twilio) use URL versioning

Cons:

  • URL proliferation: Each version multiplies endpoint count (/v1/payments, /v2/payments, /v3/payments)
  • Code duplication: May require duplicating controllers and DTOs across versions
  • Maintenance burden: Supporting multiple versions means maintaining multiple codebases

Best practices:

  • Use major version only in URL (/v1/, /v2/), not minor/patch (/v1.2.3/)
  • Default to latest stable version if no version specified (e.g., /api/payments/api/v2/payments)
  • Support at least N-1 (current and previous version) during migration periods
  • Use shared service layer across versions to avoid duplicating business logic

Header Versioning

Specify the API version using a custom HTTP header or the Accept header. URLs remain stable across versions.

Custom Header Approach:

GET /api/payments/PAY-123
API-Version: 2

200 OK
Content-Type: application/json
API-Version: 2

{ "paymentId": "PAY-123", ... }

Implementation:

@RestController
@RequestMapping("/api/payments")
public class PaymentController {

@GetMapping(value = "/{id}", headers = "API-Version=1")
public ResponseEntity<PaymentDtoV1> getPaymentV1(@PathVariable String id) {
Payment payment = paymentService.getPayment(id);
return ResponseEntity.ok()
.header("API-Version", "1")
.body(PaymentDtoV1.from(payment));
}

@GetMapping(value = "/{id}", headers = "API-Version=2")
public ResponseEntity<PaymentDtoV2> getPaymentV2(@PathVariable String id) {
Payment payment = paymentService.getPayment(id);
return ResponseEntity.ok()
.header("API-Version", "2")
.body(PaymentDtoV2.from(payment));
}

// Default to latest version if header absent
@GetMapping("/{id}")
public ResponseEntity<PaymentDtoV2> getPaymentDefault(@PathVariable String id) {
return getPaymentV2(id);
}
}

Content Negotiation Approach:

Use the Accept header with custom media types:

GET /api/payments/PAY-123
Accept: application/vnd.myapi.v2+json

200 OK
Content-Type: application/vnd.myapi.v2+json

{ "paymentId": "PAY-123", ... }

Pros:

  • Stable URLs: Resource URLs don't change across versions
  • RESTful semantics: Aligns with REST principle of versioning representations, not resources
  • Cleaner paths: No version prefix cluttering URLs
  • Content negotiation: Clients can request specific representations

Cons:

  • Reduced visibility: Version hidden in headers, harder to spot in logs, browser, and debugging tools
  • Caching complexity: Same URL can return different responses based on headers, confusing intermediary caches
  • Client configuration: Requires clients to set custom headers on every request
  • Testing overhead: Tools like Postman and curl require explicit header configuration
  • Default version ambiguity: Must define behavior when header is missing

When to use header versioning:

  • Internal APIs where you control all clients
  • APIs where resource identity must remain stable
  • Versioning primarily affects representation format, not resource structure

Query Parameter Versioning

Specify version as a query parameter: /api/payments?version=2

GET /api/payments/PAY-123?version=2

200 OK
Content-Type: application/json

{ "paymentId": "PAY-123", ... }

Implementation:

@RestController
@RequestMapping("/api/payments")
public class PaymentController {

@GetMapping("/{id}")
public ResponseEntity<?> getPayment(
@PathVariable String id,
@RequestParam(defaultValue = "2") int version) {

Payment payment = paymentService.getPayment(id);

return switch (version) {
case 1 -> ResponseEntity.ok(PaymentDtoV1.from(payment));
case 2 -> ResponseEntity.ok(PaymentDtoV2.from(payment));
default -> ResponseEntity.badRequest()
.body(Map.of("error", "Unsupported version: " + version));
};
}
}

Pros:

  • Visible in URLs: Version appears in query string, easy to see in logs and browser
  • Simple implementation: Single endpoint handles multiple versions
  • Flexible: Easy to add version parameter to existing APIs

Cons:

  • Non-standard: Rarely used in production APIs, unexpected for developers
  • Caching complications: Query parameters complicate cache keys
  • Cluttered URLs: Adds noise to every request URL
  • Versioning scope unclear: Ambiguous whether version applies to endpoint, resource, or entire API

Recommendation: Avoid query parameter versioning. It's non-standard and provides few advantages over URL or header versioning. Most developers expect either URL versioning (/v1/) or header versioning, not query parameters.

Subdomain Versioning

Use different subdomains for different API versions: v1.api.example.com, v2.api.example.com

https://v1.api.example.com/payments/PAY-123
https://v2.api.example.com/payments/PAY-123

Pros:

  • Complete isolation: Each version can run on separate infrastructure, allowing independent scaling and deployment
  • Traffic routing: Easy to route different versions to different backend clusters
  • SSL certificates: Can use separate certificates per version
  • Version visibility: Explicit in subdomain

Cons:

  • DNS management overhead: Must manage multiple DNS records and SSL certificates
  • CORS complexity: Different origins require CORS configuration for frontend clients
  • Infrastructure complexity: Deploying and monitoring multiple subdomains
  • Rarely necessary: Overkill for most APIs unless versions have fundamentally different infrastructure needs

When to use subdomain versioning:

  • Massive scale with distinct infrastructure requirements per version
  • Complete isolation needed for compliance or security
  • Legacy version runs on fundamentally different tech stack

Recommendation: Only use subdomain versioning if you have specific infrastructure isolation requirements. For most APIs, URL versioning provides sufficient separation with less operational overhead.


Backward Compatibility Techniques

Maintaining backward compatibility allows you to evolve your API without forcing clients to migrate immediately. These techniques extend the lifespan of API versions and reduce migration burden.

Additive-Only Changes

The safest evolution strategy is to only add, never remove or modify. This keeps existing clients functional while providing new capabilities.

Adding optional fields to requests:

# v1.0: Original schema
CreatePaymentRequest:
required: [amount, currency, userId]
properties:
amount:
type: number
currency:
type: string
userId:
type: string

# v1.1: Added optional metadata field (backward compatible)
CreatePaymentRequest:
required: [amount, currency, userId]
properties:
amount:
type: number
currency:
type: string
userId:
type: string
metadata: # New optional field
type: object
additionalProperties:
type: string

Existing clients send requests without metadata - requests still valid. New clients can include metadata for additional context.

Adding fields to responses:

// v1.0 response
{
"paymentId": "PAY-123",
"amount": 100.00,
"status": "completed"
}

// v1.1 response (added fields, backward compatible)
{
"paymentId": "PAY-123",
"amount": 100.00,
"status": "completed",
"createdAt": "2025-01-15T10:30:00Z", // New field
"updatedAt": "2025-01-15T11:00:00Z" // New field
}

Client resilience: Clients must ignore unknown fields. This is known as Postel's Law or the Robustness Principle: "Be conservative in what you send, liberal in what you accept."

Well-designed clients deserialize only fields they recognize and ignore extras. Poorly designed clients throw errors on unexpected fields - this is a client bug, not an API breaking change.

Field Expansion

Instead of removing fields, add new fields alongside deprecated ones. Populate both fields with the same data during a transition period.

Example: Renaming customerId to userId

// Naive approach (BREAKING):
{
"userId": "USER-123" // Removed customerId, breaks clients
}

// Backward compatible approach:
{
"customerId": "USER-123", // Deprecated but still present
"userId": "USER-123" // New canonical field
}

Implementation:

public record PaymentDto(
String paymentId,
@Deprecated(since = "1.1", forRemoval = true)
String customerId, // Kept for compatibility
String userId // New canonical field
) {
public static PaymentDto from(Payment payment) {
String userId = payment.getUserId();
return new PaymentDto(
payment.getId(),
userId, // Populate both fields with same value
userId
);
}
}

Communication:

  • Document that customerId is deprecated in favor of userId
  • Set deprecation timeline (e.g., "will be removed in v2.0 after 2026-01-01")
  • Monitor usage of deprecated field using logging or metrics
  • Remove deprecated field in next major version

Default Values

Provide sensible defaults for newly required fields to avoid breaking existing clients.

Example: Adding paymentMethod field

// Version 1.0: No payment method specified
public record CreatePaymentRequest(
BigDecimal amount,
String currency
) {}

// Version 1.1: Added paymentMethod with default
public record CreatePaymentRequest(
BigDecimal amount,
String currency,
@Schema(description = "Payment method", defaultValue = "CREDIT_CARD")
String paymentMethod // Optional with default
) {}

// Service handles both cases
public Payment createPayment(CreatePaymentRequest request) {
String method = request.paymentMethod() != null
? request.paymentMethod()
: "CREDIT_CARD"; // Default if not provided

return paymentRepository.save(new Payment(
request.amount(),
request.currency(),
method
));
}

Existing clients don't send paymentMethod; server applies default. New clients can specify it explicitly.

Versioned DTOs with Adapters

Maintain separate DTO classes for each version and use adapters to convert between them. This keeps version-specific code isolated and prevents version concerns from leaking into business logic.

// V1 DTO: Flat structure
public record PaymentDtoV1(
String id,
BigDecimal amount,
String currency,
String status
) {
public static PaymentDtoV1 from(Payment payment) {
return new PaymentDtoV1(
payment.getId(),
payment.getAmount(),
payment.getCurrency(),
payment.getStatus().name()
);
}
}

// V2 DTO: Nested structure with additional fields
public record PaymentDtoV2(
String id,
MoneyV2 money, // Nested object
PaymentStatusV2 status, // Enum with additional states
Instant createdAt
) {
public static PaymentDtoV2 from(Payment payment) {
return new PaymentDtoV2(
payment.getId(),
new MoneyV2(payment.getAmount(), payment.getCurrency()),
PaymentStatusV2.from(payment.getStatus()),
payment.getCreatedAt()
);
}
}

public record MoneyV2(BigDecimal amount, String currency) {}

// Shared service layer (version-agnostic)
@Service
public class PaymentService {
public Payment getPayment(String id) {
return paymentRepository.findById(id)
.orElseThrow(() -> new PaymentNotFoundException(id));
}
}

Benefits:

  • Business logic remains version-agnostic
  • Each version has clear, separate DTOs
  • Changes to one version don't affect others
  • Easy to test versions independently

Breaking Changes Checklist

Before introducing a breaking change, exhaust all alternatives. If a breaking change is unavoidable, follow this checklist:

1. Evaluate Necessity

  • Can this change be achieved through additive-only evolution?
  • Can deprecated fields coexist with new fields during transition?
  • Can default values provide backward compatibility?
  • Have you consulted with API consumers about impact?
  • Does the benefit of the breaking change justify the migration cost?

2. Plan the Change

  • Document exactly what's breaking and why
  • Create migration guide with before/after examples
  • Identify all affected clients and their maintainers
  • Establish deprecation timeline (minimum 6-12 months for public APIs)
  • Define sunset date for old version

3. Implement the Change

  • Create new major version endpoint (/api/v2/)
  • Update OpenAPI specification with version bump
  • Implement version-specific DTOs and controllers
  • Add deprecation warnings to old version responses
  • Update documentation with migration guide

4. Communicate the Change

  • Announce deprecation via email, API changelog, and developer portal
  • Add deprecation headers to old version responses (Deprecation: true, Sunset: Sat, 31 Dec 2025 23:59:59 GMT)
  • Update API documentation with prominent deprecation notices
  • Provide migration guide and code examples for common use cases
  • Monitor usage metrics for old version to track migration progress

5. Sunset Old Version

  • Wait until deprecation period expires and usage is minimal
  • Send final notification 30 days before sunset
  • Return 410 Gone for sunset version with message pointing to new version
  • Remove old version code from codebase
  • Archive old version documentation for reference

Deprecation Process

Deprecating an API version is a gradual, communicative process that gives clients time to migrate. Rushing deprecation damages trust and breaks integrations.

Deprecation Timeline

Public APIs: Minimum 12 months from deprecation announcement to sunset Partner APIs: Minimum 6 months with coordinated migration Internal APIs: Minimum 3 months with team coordination

Deprecation Headers

Include deprecation metadata in HTTP responses:

@GetMapping("/{id}")
public ResponseEntity<PaymentDtoV1> getPayment(@PathVariable String id) {
Payment payment = paymentService.getPayment(id);

return ResponseEntity.ok()
.header("Deprecation", "true")
.header("Sunset", "Sat, 31 Dec 2025 23:59:59 GMT") // RFC 7234
.header("Link", "</api/v2/payments>; rel=\"successor-version\"")
.body(PaymentDtoV1.from(payment));
}

Standard headers:

  • Deprecation: true: Indicates deprecated endpoint (draft IETF standard)
  • Sunset: <date>: RFC 8594 header indicating when the resource will become unavailable
  • Link: <url>; rel="successor-version": Points to replacement endpoint

These headers allow clients to programmatically detect deprecated APIs and alert developers.

Sunset Response

After the sunset date, return 410 Gone with migration guidance:

@GetMapping("/{id}")
public ResponseEntity<ErrorResponse> getPaymentSunset(@PathVariable String id) {
ErrorResponse error = new ErrorResponse(
Instant.now(),
410,
"Gone",
"API v1 was sunset on 2026-01-01. Please migrate to v2: /api/v2/payments",
"/api/v1/payments/" + id,
Map.of(
"sunsetDate", "2026-01-01",
"migrationGuide", "https://docs.example.com/api/v1-to-v2-migration",
"successorVersion", "/api/v2/payments"
)
);

return ResponseEntity.status(HttpStatus.GONE).body(error);
}

Why 410 Gone instead of 404 Not Found?

  • 404 Not Found: Resource doesn't exist (might be temporary)
  • 410 Gone: Resource permanently removed and won't return

410 Gone explicitly communicates intentional removal, prompting clients to stop retrying and migrate.


Migration Paths for Consumers

Provide clear, actionable migration guides that minimize friction for API consumers. Good migration documentation includes:

Migration Guide Structure

  1. Summary of Changes

    • High-level overview of what changed and why
    • Benefits of migrating (new features, performance, security)
  2. Breaking Changes List

    • Exhaustive list of incompatible changes
    • Before/after examples for each change
  3. Step-by-Step Migration

    • Concrete steps for migrating code
    • Code examples in common languages (Java, TypeScript, Python)
    • Testing strategies to verify migration success
  4. Deprecation Timeline

    • Key dates: deprecation announcement, sunset date, removal date
    • How to check if you're using deprecated version
  5. Support Resources

    • Contact information for migration support
    • FAQ addressing common migration issues
    • Links to updated API documentation

Example Migration Guide

Migrating from v1 to v2: Payment API

Summary: Version 2 introduces nested structure for money representation and adds support for multi-currency payments.

Breaking Changes:

Changev1v2Action Required
Money representationFlat: amount, currencyNested: money: { amount, currency }Update deserialization to nested structure
Status enumPENDING, COMPLETED, FAILEDAdded PROCESSING, CANCELLEDHandle new status values
Date formatYYYY-MM-DD stringsISO 8601 InstantParse ISO 8601 timestamps

Migration Steps:

  1. Update DTOs:
// v1 response type
interface PaymentV1 {
id: string;
amount: number;
currency: string;
status: 'PENDING' | 'COMPLETED' | 'FAILED';
}

// v2 response type
interface PaymentV2 {
id: string;
money: {
amount: number;
currency: string;
};
status: 'PENDING' | 'PROCESSING' | 'COMPLETED' | 'FAILED' | 'CANCELLED';
createdAt: string; // ISO 8601
}
  1. Update API calls:
// v1 API call
const response = await fetch('/api/v1/payments/PAY-123');
const payment: PaymentV1 = await response.json();
console.log(payment.amount); // Direct access

// v2 API call
const response = await fetch('/api/v2/payments/PAY-123');
const payment: PaymentV2 = await response.json();
console.log(payment.money.amount); // Nested access
  1. Test thoroughly:
    • Verify all API integrations work with v2
    • Test error handling for new status values
    • Validate date parsing for ISO 8601 format

Timeline:

  • v1 deprecated: 2025-01-01
  • v1 sunset: 2026-01-01
  • v2 stable since: 2025-01-01

Support: Contact [email protected] for migration assistance.


Version Support Strategy

How Many Versions to Support?

Recommended: Support N and N-1 (current version and one previous version)

Rationale:

  • Gives clients time to migrate without pressure
  • Limits maintenance burden to two active versions
  • Provides safety net if new version has issues

Example:

  • v2 (current, stable): Recommended for all new integrations
  • v1 (deprecated): Supported until 2026-01-01, receiving only critical security fixes
  • v0 (sunset): No longer available, returns 410 Gone

Version Support Levels

VersionSupport LevelUpdates ProvidedDeprecation Status
Current (v2)Full supportNew features, bug fixes, security patchesNot deprecated
Previous (v1)MaintenanceCritical bug fixes, security patches onlyDeprecated, sunset date announced
Older (v0)NoneNo updatesSunset, returns 410 Gone

Exception: Critical Security Fixes

Even sunset versions should receive emergency security patches if they pose active security risks and meaningful usage remains. Balance security responsibility against encouraging migration.


OpenAPI Versioning Best Practices

OpenAPI specifications serve as the contract for your API. Version them carefully and explicitly. For comprehensive OpenAPI details, see OpenAPI Specifications.

Separate Spec Files Per Major Version

Maintain distinct OpenAPI specification files for each major version:

specs/
├── openapi-v1.yml
├── openapi-v2.yml
└── openapi-v3.yml

Each spec file defines the complete contract for that version, including:

  • Endpoints and paths
  • Request/response schemas
  • Validation rules
  • Authentication requirements

Benefits:

  • Clear separation of version contracts
  • Independent evolution of specs
  • Easy to reference specific version contract
  • Version-specific code generation

Document Changes in Changelog

Maintain a changelog in your OpenAPI spec or separate markdown file:

# openapi-v2.yml
openapi: 3.0.3
info:
title: Payment Service API
version: 2.0.0
description: |
Payment processing API

## Changelog

### v2.0.0 (2025-01-01) - BREAKING CHANGES
- **BREAKING**: Changed money representation to nested structure
- **BREAKING**: Added new status values (PROCESSING, CANCELLED)
- **BREAKING**: Date fields now use ISO 8601 format
- Added `metadata` field for extensibility
- Improved error response format

### v1.0.0 (2024-01-01)
- Initial release

Version in info.version

Use semantic versioning in the spec:

info:
title: Payment Service API
version: 2.1.0 # MAJOR.MINOR.PATCH

Increment based on change type:

  • MAJOR: Breaking changes
  • MINOR: New features, backward compatible
  • PATCH: Bug fixes, backward compatible

Further Reading

Internal Documentation

External Resources


Summary

Key Takeaways:

  1. Version only when necessary - Maintain backward compatibility through additive changes whenever possible
  2. Breaking changes mandate new versions - Removing fields, changing types, or altering behavior requires versioning
  3. URL versioning is most common - Explicit, visible, cacheable, and widely adopted (/api/v1/, /api/v2/)
  4. Deprecate gracefully - Minimum 6-12 months notice, clear communication, migration guides, deprecation headers
  5. Support N and N-1 - Current version and one previous version minimizes maintenance burden
  6. Use semantic versioning - MAJOR for breaking changes, MINOR for features, PATCH for fixes
  7. Separate version concerns - Versioned DTOs and controllers, shared service layer
  8. Document exhaustively - Migration guides, changelogs, deprecation timelines in OpenAPI specs
  9. Communicate proactively - Email announcements, deprecation headers, sunset notices
  10. Plan for evolution - Design APIs that can grow through additive changes

Next Steps: Review OpenAPI Specifications for documenting versioned APIs, then API Contracts for implementing version-specific DTOs.