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.
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.
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.
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.
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.
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.
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
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.
Interface for extracting version from a request. Built-in: UrlSegmentApiVersionReader, QueryStringApiVersionReader, HeaderApiVersionReader, MediaTypeApiVersionReader. Combine multiple with ApiVersionReader.Combine().
Attribute on controller action methods to specify which API version(s) the action handles when multiple versions are declared on the same controller class.
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.
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.
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-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.
When true, adds api-supported-versions and api-deprecated-versions response headers. Allows API consumers to discover available versions programmatically without reading documentation.
1// Install: dotnet add package Asp.Versioning.Mvc (ASP.NET Core 9)23// Program.cs4builder.Services.AddApiVersioning(options =>5{6 // What version to assume when none is specified7 options.DefaultApiVersion = new ApiVersion(1, 0);8 options.AssumeDefaultVersionWhenUnspecified = true;910 // Include api-supported-versions / api-deprecated-versions in response headers11 options.ReportApiVersions = true;1213 // 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();2021// Controller: multiple versions on one class, or split into separate classes22[ApiController]23[Route("api/v{version:apiVersion}/orders")]24[ApiVersion("3.0")]25[ApiVersion("2.0", Deprecated = true)] // still works, but sends Sunset/Deprecation headers26public class OrdersController : ControllerBase27{28 private readonly ILogger<OrdersController> _logger;2930 public OrdersController(ILogger<OrdersController> logger) => _logger = logger;3132 [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 status39 var order = await _orderService.GetWithLineItemsAsync(orderId, cancellationToken);40 if (order is null) return NotFound();41 return Ok(new OrderV3Response(order));42 }4344 [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);5455 var order = await _orderService.GetAsync(orderId, cancellationToken);56 if (order is null) return NotFound();57 return Ok(new OrderV2Response(order)); // old response shape58 }59}6061// Version-neutral endpoint (no versioning applied β e.g. health checks)62[ApiController]63[ApiVersionNeutral]64[Route("health")]65public class HealthController : ControllerBase66{67 [HttpGet] public IActionResult Get() => Ok(new { status = "healthy" });68}6970// 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();7778app.MapGet("/api/products/{id}", GetProduct)79 .WithApiVersionSet(versionSet)80 .MapToApiVersion(new ApiVersion(4, 0));
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
1Breaking Change Forced Every Client to Update Simultaneously
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.
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.
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
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.
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.
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
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.
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.
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.