API Versioning — Production-Ready Strategies for Managing API Versions
Posted on: 4/25/2026 4:13:34 AM
Table of contents
- 1. Why API Versioning Matters
- 2. Four Common Versioning Strategies
- 3. Detailed Strategy Comparison
- 4. Case Studies: Stripe and GitHub
- 5. Implementation on ASP.NET Core with Asp.Versioning
- 6. API Versioning at the API Gateway Level
- 7. Managing Breaking Changes
- 8. Professional Deprecation Roadmap
- 9. Versioning for GraphQL APIs
- 10. Anti-Patterns to Avoid
- 11. Monitoring and Observability
- 12. Conclusion
- References
API Versioning is one of the most critical architectural decisions when building microservices. A solid versioning strategy lets you evolve your API continuously without breaking thousands of active clients. This article provides a deep analysis of versioning patterns, compares approaches from Stripe, GitHub, and Google, and walks through practical implementation on .NET 10.
1. Why API Versioning Matters
As systems evolve, APIs cannot remain static. Business requirements change, data models evolve, and you need to fix early design mistakes. But every breaking change can crash thousands of client applications depending on your API.
API Versioning solves this by allowing multiple API versions to coexist simultaneously, enabling clients to transition on their own schedule rather than being forced.
What Is a Breaking Change?
Any change that causes existing clients to fail: removing a field, renaming a field, changing a data type, removing an endpoint, changing HTTP status codes, or altering business logic that produces different responses. Conversely, adding a new field to a response is typically NOT a breaking change if clients ignore unknown fields.
2. Four Common Versioning Strategies
2.1. URL Path Versioning
The version is embedded directly in the URL path. This is the most popular method, used by Twitter, Facebook, and most public APIs.
GET /api/v1/products/123
GET /api/v2/products/123
Pros: Easy to understand, testable in browsers, cache-friendly since different URLs create different cache keys, easy to route at the API Gateway level.
Cons: URLs change with new versions, violating the principle that "a URI represents a resource." Multiple versions lead to duplicated routes.
2.2. Query String Versioning
GET /api/products/123?api-version=1.0
GET /api/products/123?api-version=2.0
Pros: Base URL stays the same, easy to add version to existing requests. Clients just append a query parameter.
Cons: Easy to forget passing the version, requires default version handling. Some CDNs don't cache well with query strings.
2.3. Header Versioning
GET /api/products/123
X-Api-Version: 1.0
GET /api/products/123
X-Api-Version: 2.0
Pros: Completely clean URLs, separates resource identity from version concerns. Ideal when the API serves different clients needing different versions on the same resource.
Cons: Cannot test directly in browsers, harder to debug, clients must configure headers on every request.
2.4. Date-Based Versioning (Stripe/GitHub Style)
GET /v1/charges
Stripe-Version: 2026-04-22
GET /api/repos
X-GitHub-Api-Version: 2026-03-10
Pros: Extremely granular — each release date can be a version. Clients are pinned to a specific point in time, guaranteeing behavior won't change. Stripe automatically pins accounts to the version when first created.
Cons: More complex to implement — requires maintaining a compatibility layer for every old version. Only suitable for teams with mature API operations.
graph TD
A["Client Request"] --> B{"Version Detection"}
B -->|URL Path| C["/api/v1/products"]
B -->|Query String| D["/api/products?v=1.0"]
B -->|Header| E["X-Api-Version: 1.0"]
B -->|Date-Based| F["Stripe-Version: 2026-04-22"]
C --> G["Version Router"]
D --> G
E --> G
F --> G
G --> H["Controller v1"]
G --> I["Controller v2"]
G --> J["Compatibility Layer"]
style A fill:#e94560,stroke:#fff,color:#fff
style B fill:#2c3e50,stroke:#fff,color:#fff
style G fill:#16213e,stroke:#fff,color:#fff
style H fill:#f8f9fa,stroke:#e94560,color:#2c3e50
style I fill:#f8f9fa,stroke:#e94560,color:#2c3e50
style J fill:#f8f9fa,stroke:#e94560,color:#2c3e50
API Version Detection flow
3. Detailed Strategy Comparison
| Criteria | URL Path | Query String | Header | Date-Based |
|---|---|---|---|---|
| Popularity | Very high (83%) | Medium | Low | Low (specialized) |
| Ease of use | Very easy | Easy | Medium | Hard |
| Cache-friendly | Good | Medium | Requires Vary config | Requires Vary config |
| Clean URLs | No | No | Yes | Yes |
| Browser testable | Easy | Easy | Not possible | Not possible |
| Version granularity | Major only | Major.Minor | Custom | Very granular (daily) |
| Best for | Public APIs | Internal APIs | B2B APIs | Fintech, PaaS |
4. Case Studies: Stripe and GitHub
4.1. Stripe — Sophisticated Date-Based Versioning
Stripe uses a date-based versioning system where each version is a date string (e.g., 2026-04-22). When a developer creates their Stripe account for the first time, the API version at that moment is automatically pinned to their account. All requests from that account use that version unless overridden with the Stripe-Version header.
This approach ensures that developer code never spontaneously breaks — even as Stripe releases hundreds of changes per year. When ready to upgrade, developers proactively bump their version via the dashboard or header.
Lessons from Stripe
Stripe maintains a massive compatibility layer — each time there's a new breaking change, they write a "version change" transformer that converts the new response into the old format for older versions. These transformers are chained: a request for v2024-01-01 passes through every transformer from the current version back to 2024-01-01. This model is extremely effective but demands high engineering discipline.
4.2. GitHub — Date-Based with 24-Month Support
GitHub's REST API uses date-based versions (e.g., 2026-03-10), passed via the X-GitHub-Api-Version header. Each version is supported for at least 24 months after a new version is released. GitHub also publishes a clear list of breaking changes for each version in their documentation.
5. Implementation on ASP.NET Core with Asp.Versioning
The Asp.Versioning library (formerly Microsoft.AspNetCore.Mvc.Versioning) is the official solution for API versioning on .NET. It supports all 4 versioning strategies and integrates deeply with ASP.NET Core.
5.1. Installation
dotnet add package Asp.Versioning.Http
dotnet add package Asp.Versioning.Mvc.ApiExplorer
5.2. Basic Configuration
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddApiVersioning(options =>
{
options.DefaultApiVersion = new ApiVersion(1, 0);
options.AssumeDefaultVersionWhenUnspecified = true;
options.ReportApiVersions = true;
options.ApiVersionReader = ApiVersionReader.Combine(
new UrlSegmentApiVersionReader(),
new HeaderApiVersionReader("X-Api-Version"),
new QueryStringApiVersionReader("api-version")
);
})
.AddApiExplorer(options =>
{
options.GroupNameFormat = "'v'VVV";
options.SubstituteApiVersionInUrl = true;
});
5.3. Controller Versioning
[ApiController]
[Route("api/v{version:apiVersion}/[controller]")]
[ApiVersion("1.0")]
public class ProductsController : ControllerBase
{
[HttpGet("{id}")]
public IActionResult GetProduct(int id)
{
return Ok(new { Id = id, Name = "Widget", Price = 29.99 });
}
}
[ApiController]
[Route("api/v{version:apiVersion}/[controller]")]
[ApiVersion("2.0")]
public class ProductsV2Controller : ControllerBase
{
[HttpGet("{id}")]
public IActionResult GetProduct(int id)
{
return Ok(new
{
Id = id,
Name = "Widget",
Price = new { Amount = 29.99, Currency = "USD" },
Category = "Electronics",
CreatedAt = DateTime.UtcNow
});
}
}
5.4. Minimal API Versioning
var versionSet = app.NewApiVersionSet()
.HasApiVersion(new ApiVersion(1, 0))
.HasApiVersion(new ApiVersion(2, 0))
.ReportApiVersions()
.Build();
app.MapGet("/api/v{version:apiVersion}/products/{id}", (int id) =>
Results.Ok(new { Id = id, Name = "Widget" }))
.WithApiVersionSet(versionSet)
.MapToApiVersion(new ApiVersion(1, 0));
app.MapGet("/api/v{version:apiVersion}/products/{id}", (int id) =>
Results.Ok(new { Id = id, Name = "Widget",
Price = new { Amount = 29.99, Currency = "USD" } }))
.WithApiVersionSet(versionSet)
.MapToApiVersion(new ApiVersion(2, 0));
5.5. Deprecation with Sunset Header
[ApiVersion("1.0", Deprecated = true)]
[ApiVersion("2.0")]
public class ProductsController : ControllerBase
{
[HttpGet("{id}"), MapToApiVersion("1.0")]
public IActionResult GetV1(int id) => Ok(new { Id = id });
[HttpGet("{id}"), MapToApiVersion("2.0")]
public IActionResult GetV2(int id) => Ok(new { Id = id, Category = "Electronics" });
}
When Deprecated = true, ASP.NET Core automatically adds the api-deprecated-versions: 1.0 header to every response. You can add middleware to inject the Sunset header (RFC 8594):
app.Use(async (context, next) =>
{
await next();
var feature = context.Features.Get<IApiVersioningFeature>();
if (feature?.RequestedApiVersion?.MajorVersion == 1)
{
context.Response.Headers["Sunset"] = "Sat, 01 Nov 2026 00:00:00 GMT";
context.Response.Headers["Deprecation"] = "true";
context.Response.Headers["Link"] =
"<https://api.example.com/docs/migration-v2>; rel=\"successor-version\"";
}
});
sequenceDiagram
participant C as Client
participant GW as API Gateway
participant R as Version Router
participant V1 as Controller v1 (deprecated)
participant V2 as Controller v2
C->>GW: GET /api/v1/products/1
GW->>R: Route to v1
R->>V1: Handle request
V1-->>C: 200 OK + Sunset: 2026-11-01
Note over C: Client receives deprecation warning
C->>GW: GET /api/v2/products/1
GW->>R: Route to v2
R->>V2: Handle request
V2-->>C: 200 OK (full response)
Deprecation flow with Sunset header
6. API Versioning at the API Gateway Level
In microservices architecture, the API Gateway plays a central role in version management. Instead of each service handling versioning independently, the Gateway can route requests to the correct service version.
graph LR
C["Client"] --> GW["API Gateway
(YARP / Kong)"]
GW -->|v1/*| S1A["Service A - v1"]
GW -->|v2/*| S1B["Service A - v2"]
GW -->|v1/*| S2["Service B - v1"]
GW -->|v2/*| S2B["Service B - v2"]
S1A --> DB1["Database"]
S1B --> DB1
S2 --> DB2["Database"]
S2B --> DB2
style GW fill:#e94560,stroke:#fff,color:#fff
style C fill:#2c3e50,stroke:#fff,color:#fff
style S1A fill:#f8f9fa,stroke:#e0e0e0,color:#2c3e50
style S1B fill:#f8f9fa,stroke:#e94560,color:#2c3e50
style S2 fill:#f8f9fa,stroke:#e0e0e0,color:#2c3e50
style S2B fill:#f8f9fa,stroke:#e94560,color:#2c3e50
API Gateway routing by version
6.1. YARP (Yet Another Reverse Proxy) on .NET
{
"Routes": {
"products-v1": {
"ClusterId": "products-v1",
"Match": { "Path": "/api/v1/products/{**catch-all}" }
},
"products-v2": {
"ClusterId": "products-v2",
"Match": { "Path": "/api/v2/products/{**catch-all}" }
}
},
"Clusters": {
"products-v1": {
"Destinations": {
"primary": { "Address": "https://products-v1.internal:5001/" }
}
},
"products-v2": {
"Destinations": {
"primary": { "Address": "https://products-v2.internal:5002/" }
}
}
}
}
6.2. Two Multi-Version Deployment Strategies
| Strategy | Description | When to Use |
|---|---|---|
| Same codebase | All versions in one project, using [ApiVersion] attributes to differentiate |
Small changes, few breaking changes |
| Separate services | Each version is a separate deployment, Gateway routes by version | Major changes, completely different schemas |
7. Managing Breaking Changes
7.1. Additive Change Strategy
The golden rule: always add, never remove or rename. If you follow this rule, you almost never need to create a new version.
- Adding a new field to response — NOT breaking
- Adding an optional parameter to request — NOT breaking
- Adding a new endpoint — NOT breaking
- Removing a field from response — BREAKING
- Renaming a field — BREAKING
- Changing data type (string → object) — BREAKING
- Changing HTTP status code — BREAKING
7.2. Compatibility Layer Pattern (Stripe-style)
Instead of maintaining separate codebases for each version, you write the latest code and create transformers that convert responses back to older formats:
public interface IVersionTransformer
{
ApiVersion TargetVersion { get; }
object Transform(object currentResponse);
}
public class ProductV1Transformer : IVersionTransformer
{
public ApiVersion TargetVersion => new(1, 0);
public object Transform(object currentResponse)
{
if (currentResponse is ProductV2Response v2)
{
return new ProductV1Response
{
Id = v2.Id,
Name = v2.Name,
Price = v2.Price.Amount // v2 has Price object, v1 only has decimal
};
}
return currentResponse;
}
}
graph LR
A["Request v1"] --> B["Latest Controller"]
B --> C["Response v3 format"]
C --> D["Transformer v3→v2"]
D --> E["Transformer v2→v1"]
E --> F["Response v1 format"]
style A fill:#e94560,stroke:#fff,color:#fff
style B fill:#2c3e50,stroke:#fff,color:#fff
style F fill:#4CAF50,stroke:#fff,color:#fff
style C fill:#f8f9fa,stroke:#e94560,color:#2c3e50
style D fill:#f8f9fa,stroke:#e94560,color:#2c3e50
style E fill:#f8f9fa,stroke:#e94560,color:#2c3e50
Compatibility Layer — chaining transformers from newest to oldest version
8. Professional Deprecation Roadmap
Deprecated = true to the old version. Email developers, update changelog.Sunset header with a specific date. Monitor traffic — what percentage of clients still use the old version?410 Gone for the old version, with a link to documentation guiding the upgrade.9. Versioning for GraphQL APIs
GraphQL follows a "versionless" philosophy — there are no explicit versions. Instead, the schema evolves continuously through:
- Adding new fields — clients only query the fields they need, new fields don't affect them
- Deprecating fields using the
@deprecated(reason: "Use priceV2 instead")directive - Never removing fields — only deprecate and keep them indefinitely or until no client queries them
type Product {
id: ID!
name: String!
price: Float @deprecated(reason: "Use priceV2 for currency support")
priceV2: Money!
category: String!
}
type Money {
amount: Float!
currency: String!
}
GraphQL vs REST Versioning
With GraphQL, you rarely need versioning because clients self-select fields. But if you have behavior changes (not just schema changes), you still need a versioning mechanism — for example, using a X-Schema-Version header or a separate endpoint like /graphql/v2.
10. Anti-Patterns to Avoid
Common Mistakes
1. Too many versions: Maintaining 5+ versions simultaneously is a nightmare. Stripe is the exception because they have a dedicated team. Most teams should limit to 2-3 active versions.
2. Versioning when unnecessary: Don't create v2 just to add a new field. Additive changes don't need a new version.
3. Ignoring default version: When AssumeDefaultVersionWhenUnspecified = false, legacy clients not sending a version will get 400 Bad Request. Always set to true during the transition period.
4. Not tracking usage: If you don't know how many clients are using v1, you don't know when it's safe to shut it down.
5. Unannounced breaking changes: Changing behavior without bumping the version is the most serious contract violation.
11. Monitoring and Observability
Tracking usage per API version is essential for deprecation decisions. Integrate OpenTelemetry to record metrics:
app.Use(async (context, next) =>
{
var version = context.GetRequestedApiVersion()?.ToString() ?? "unspecified";
var meter = context.RequestServices.GetRequiredService<Meter>();
var counter = meter.CreateCounter<long>("api.requests.by_version");
counter.Add(1, new KeyValuePair<string, object?>("api.version", version));
await next();
});
With this metric, you can create a Grafana dashboard showing:
- Traffic distribution by version (% v1 vs v2)
- Migration trend — v1 traffic decreasing over time
- Alerts when a deprecated version still has high traffic past its deadline
12. Conclusion
API Versioning isn't just a technical decision — it's a commitment to developers using your API that their code won't spontaneously break. Choose the strategy that fits your team size and client audience:
- Public API, small team → URL Path Versioning + limit to 2-3 versions
- Internal microservices API → Header Versioning + API Gateway routing
- Large fintech/platform API → Date-Based Versioning (Stripe model)
- GraphQL → Schema evolution +
@deprecateddirective
Regardless of which strategy you choose, always prioritize additive changes, implement Sunset headers when deprecating, and monitor usage with OpenTelemetry. A great API is one that developers trust — and versioning is the foundation of that trust.
References
- APIs as infrastructure: future-proofing Stripe with versioning — Stripe Engineering Blog
- Breaking changes — GitHub REST API Documentation
- ASP.NET API Versioning — GitHub Repository
- API Versioning in ASP.NET Core — Milan Jovanovic
- Feature Flags and Feature Management Architecture 2026 — Zylos Research
- API Versioning in ASP.NET Core — Code Maze
Disclaimer: The opinions expressed in this blog are solely my own and do not reflect the views or opinions of my employer or any affiliated organizations. The content provided is for informational and educational purposes only and should not be taken as professional advice. While I strive to provide accurate and up-to-date information, I make no warranties or guarantees about the completeness, reliability, or accuracy of the content. Readers are encouraged to verify the information and seek independent advice as needed. I disclaim any liability for decisions or actions taken based on the content of this blog.