AI WisdomArchitecture & guides β†—
HT
How Things Work

API Versioning Strategies

URL path, query string, or header versioning β€” each has trade-offs that will bite you when you're on v4 with 3 active clients.

How It Works

API versioning is how you evolve a contract without breaking existing consumers. ASP.NET Core 9 with Asp.Versioning.Mvc provides three strategies β€” URL segment, query string, and header β€” each with distinct behavior around caching, discoverability, and CDN compatibility. The right choice depends on your client landscape, not convention.

1
Request arrives β€” version reader extracts the version

ApiVersioningMiddleware intercepts the request and uses the configured IApiVersionReader(s) to extract the requested API version from the URL segment, query string, or header. Multiple readers can be combined β€” the framework tries each in order.

2
Version is matched to registered ApiVersions

The extracted version is compared against all controllers/endpoints decorated with [ApiVersion]. If AssumeDefaultVersionWhenUnspecified is true and no version is present, the DefaultApiVersion is used. An unregistered version returns 400 Bad Request with a Problem Details body.

3
[MapToApiVersion] routes to the right action

When a controller handles multiple versions, [MapToApiVersion] disambiguates which action method handles which version. Without it, all actions in the class apply to all versions declared on the class.

4
Deprecated versions still work β€” with warning headers

Marking a version as Deprecated = true does NOT remove it. The endpoint continues to function and ReportApiVersions injects api-deprecated-versions response headers. For RFC 8594 compliance, manually add Sunset and Deprecation response headers to give clients a removal timeline.

5
ApiVersionNeutral endpoints skip versioning entirely

Health checks, metadata endpoints, and OpenAPI documents shouldn't be versioned. Decorate with [ApiVersionNeutral] to bypass the versioning middleware entirely. These routes match regardless of what version header or segment is present.

Key Concepts

πŸ“¦Asp.Versioning.Mvc

The official NuGet package (formerly Microsoft.AspNetCore.Mvc.Versioning). Provides AddApiVersioning(), all reader strategies, and [ApiVersion]/[MapToApiVersion] attributes. Install Asp.Versioning.Mvc.ApiExplorer for Swagger integration.

πŸ”ApiVersionReader

Interface for extracting version from a request. Built-in: UrlSegmentApiVersionReader, QueryStringApiVersionReader, HeaderApiVersionReader, MediaTypeApiVersionReader. Combine multiple with ApiVersionReader.Combine().

πŸ—ΊοΈMapToApiVersion

Attribute on controller action methods to specify which API version(s) the action handles when multiple versions are declared on the same controller class.

⚠️Deprecation vs Removal

Deprecated = true marks a version as outdated but keeps it functional. Use Sunset response headers (RFC 8594) to communicate removal dates. Only remove a version after your monitoring shows zero usage β€” usually 6-12 months after sunset.

πŸ”„ApiVersionNeutral

Controller/endpoint attribute that bypasses all versioning logic. Use for health checks, metrics endpoints, OpenAPI docs, or any endpoint that all API versions share identically.

πŸ—‚οΈVersion Sets (Minimal APIs)

NewApiVersionSet() creates a version set that can be attached to route groups or individual endpoints with .WithApiVersionSet(). Cleaner than attributes for Minimal API style architectures.

⚑URL vs Header Caching

URL-based versioning (/v1/users vs /v2/users) creates distinct cache keys β€” CDNs cache them independently with no configuration. Header versioning requires Vary: api-version on responses, or CDNs will serve v1 responses for v2 requests.

πŸ“‹ReportApiVersions

When true, adds api-supported-versions and api-deprecated-versions response headers. Allows API consumers to discover available versions programmatically without reading documentation.

API Versioning with Asp.Versioning.Mvc (ASP.NET Core 9)
tsx
1// Install: dotnet add package Asp.Versioning.Mvc (ASP.NET Core 9)
2
3// Program.cs
4builder.Services.AddApiVersioning(options =>
5{
6 // What version to assume when none is specified
7 options.DefaultApiVersion = new ApiVersion(1, 0);
8 options.AssumeDefaultVersionWhenUnspecified = true;
9
10 // Include api-supported-versions / api-deprecated-versions in response headers
11 options.ReportApiVersions = true;
12
13 // Choose your strategy (or combine multiple readers with ApiVersionReader.Combine)
14 options.ApiVersionReader = ApiVersionReader.Combine(
15 new UrlSegmentApiVersionReader(),
16 new HeaderApiVersionReader("api-version"),
17 new QueryStringApiVersionReader("api-version")
18 );
19}).AddMvc();
20
21// Controller: multiple versions on one class, or split into separate classes
22[ApiController]
23[Route("api/v{version:apiVersion}/orders")]
24[ApiVersion("3.0")]
25[ApiVersion("2.0", Deprecated = true)] // still works, but sends Sunset/Deprecation headers
26public class OrdersController : ControllerBase
27{
28 private readonly ILogger<OrdersController> _logger;
29
30 public OrdersController(ILogger<OrdersController> logger) => _logger = logger;
31
32 [HttpGet("{orderId:guid}")]
33 [MapToApiVersion("3.0")]
34 public async Task<IActionResult> GetOrderV3(
35 Guid orderId,
36 CancellationToken cancellationToken)
37 {
38 // V3 includes nested line items and fulfillment status
39 var order = await _orderService.GetWithLineItemsAsync(orderId, cancellationToken);
40 if (order is null) return NotFound();
41 return Ok(new OrderV3Response(order));
42 }
43
44 [HttpGet("{orderId:guid}")]
45 [MapToApiVersion("2.0")]
46 public async Task<IActionResult> GetOrderV2(
47 Guid orderId,
48 CancellationToken cancellationToken)
49 {
50 _logger.LogWarning(
51 "Client called deprecated v2 endpoint for order {OrderId}. " +
52 "TraceId: {TraceId}",
53 orderId, HttpContext.TraceIdentifier);
54
55 var order = await _orderService.GetAsync(orderId, cancellationToken);
56 if (order is null) return NotFound();
57 return Ok(new OrderV2Response(order)); // old response shape
58 }
59}
60
61// Version-neutral endpoint (no versioning applied β€” e.g. health checks)
62[ApiController]
63[ApiVersionNeutral]
64[Route("health")]
65public class HealthController : ControllerBase
66{
67 [HttpGet] public IActionResult Get() => Ok(new { status = "healthy" });
68}
69
70// Minimal API version sets (ASP.NET Core 7+ / Asp.Versioning 7+)
71var versionSet = app.NewApiVersionSet()
72 .HasApiVersion(new ApiVersion(3, 0))
73 .HasApiVersion(new ApiVersion(4, 0))
74 .HasDeprecatedApiVersion(new ApiVersion(2, 0))
75 .ReportApiVersions()
76 .Build();
77
78app.MapGet("/api/products/{id}", GetProduct)
79 .WithApiVersionSet(versionSet)
80 .MapToApiVersion(new ApiVersion(4, 0));
πŸ’‘
Why This Matters

The moment you make your API public or have more than one consumer, you've entered a versioning contract β€” whether you acknowledge it or not. Breaking changes on unversioned APIs cause production incidents for clients you may not even know about. Proper versioning with deprecation timelines and sunset headers gives you the ability to evolve your API without coordination ceremonies.

Common Pitfalls

⚠Forgetting Vary: api-version when using header versioning. CDNs use URL as cache key by default β€” all versions share one cache entry, causing clients to silently receive responses from the wrong version. Always add Vary: api-version or use URL versioning if you have a CDN.
⚠Default version handling: AssumeDefaultVersionWhenUnspecified = true means unversioned requests silently use DefaultApiVersion. If DefaultApiVersion points to a deprecated version, new clients build against outdated contracts without any warning.
⚠Treating Deprecated = true as equivalent to removal. Deprecated versions continue to serve traffic β€” you must actively monitor usage metrics and only remove a version after confirmed zero usage over an extended period (typically 30+ days).
⚠URL versioning breaks semantic URL caching. /api/v1/users and /api/v2/users are separate resources β€” but they may represent the same entity at different points in time. Consider this in your cache invalidation strategy.
Real-World Use Cases

1Breaking Change Forced Every Client to Update Simultaneously

Scenario

A B2B platform had 23 enterprise clients calling /api/users. The backend team renamed the email field to emailAddress in a schema change. Three clients broke in production within 30 minutes. Emergency rollback cost 4 hours of engineering time and a SLA penalty.

Problem

There was no versioning. Every client called the same unversioned endpoint. A breaking change had no migration path β€” it was a big bang deployment that all clients had to absorb simultaneously, or none could.

Solution

Introduce Asp.Versioning.Mvc with the existing endpoint as v1. Roll out the new schema as v2. Set v1 to Deprecated = true with a Sunset header 6 months out. Email all client integration contacts with the migration guide. Monitor api-version usage metrics to track migration progress. Once usage drops to zero, remove v1.

πŸ’‘

Takeaway: API versioning is primarily about decoupling your deployment cycle from your clients' deployment cycle. Without it, every breaking change requires synchronized deployments across all consumers β€” which is operationally impossible at scale.

2CDN Serving Wrong Version After Header Versioning Rollout

Scenario

The team switched from URL versioning to header versioning to 'clean up the URLs'. Three days later, client A (sending api-version: 2) started receiving v1 responses intermittently. Investigation found that Azure Front Door was caching the first response for /api/users/1 regardless of the api-version header.

Problem

CDNs use the URL as the default cache key. Without Vary: api-version, all requests to /api/users/1 share a single cache entry β€” regardless of which version header was sent. The first request to populate the cache for a given URL determines what every subsequent request receives.

Solution

Add Vary: api-version to all responses when using header versioning, or add cache-busting middleware. Alternatively, revert to URL versioning β€” /v1/ and /v2/ are distinct URLs and CDNs handle them correctly with zero configuration. Header versioning requires explicit CDN configuration that most teams forget.

πŸ’‘

Takeaway: Header versioning is architecturally clean but operationally dangerous with CDN layers. URL versioning is verbose but predictably cacheable. Choose based on your infrastructure, not aesthetics.

3Default Version Trap β€” New Clients Silently Using Deprecated API

Scenario

AssumeDefaultVersionWhenUnspecified was set to true with DefaultApiVersion = 1.0. v1.0 was deprecated and sunset-dated 6 months prior. A new frontend team built a new mobile app without reading the API docs. Every call went to v1.0 (the default), and they didn't realize it until v1.0 was actually removed in a cleanup sprint.

Problem

With AssumeDefaultVersionWhenUnspecified = true, clients that don't specify a version silently use whatever the default is. If the default is a deprecated or old version, new clients build against an already-obsolete API. There is no warning at integration time.

Solution

Set DefaultApiVersion to your latest stable version and update it with each release. Consider setting AssumeDefaultVersionWhenUnspecified = false in production after an initial grace period β€” clients that don't specify a version get a 400 with a helpful error message listing available versions. This forces clients to make an explicit versioning choice.

πŸ’‘

Takeaway: AssumeDefaultVersionWhenUnspecified is a convenience for migration, not a permanent production setting. Defaulting to the latest version creates pressure on clients to opt into new versions explicitly rather than silently inheriting whatever is current.