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: customerId → userId breaks existing clients
  • Changing field type: amount: string → amount: 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.