Sooner or later every API that has real consumers needs to change in ways that are not backward-compatible. You rename a field, remove a property, change a response shape, or restructure a route. Without versioning, any of those changes breaks existing clients immediately. With versioning, you introduce the new contract alongside the old one, communicate a deprecation timeline, and let clients migrate at a pace that works for them.
By the end of this post you will have a .NET 8 Web API that supports URL segment versioning, query string versioning, and custom header versioning simultaneously using the official Asp.Versioning.Http library. You will also see how to mark versions as deprecated so API consumers get a clear signal that they need to migrate.
Before starting, make sure you have:
- .NET 8 SDK (8.0.100 or later)
- An existing ASP.NET Core Web API project targeting .NET 8
- NuGet package:
Asp.Versioning.Mvc(for controller-based APIs) orAsp.Versioning.Http(for Minimal APIs)
Asp.Versioning.* packages are the official successor to the archived Microsoft.AspNetCore.Mvc.Versioning packages. If your project still references the old packages, plan a migration; they are no longer maintained and do not support .NET 7 or .NET 8 correctly.
Add the package to your API project and register the versioning services in Program.cs.
// From the terminal — add the NuGet package
// dotnet add package Asp.Versioning.Mvc
// dotnet add package Asp.Versioning.Mvc.ApiExplorer // for Swagger integration
// Program.cs — versioning service registration
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.AddApiVersioning(options =>
{
options.DefaultApiVersion = new ApiVersion(1, 0);
options.AssumeDefaultVersionWhenUnspecified = true;
options.ReportApiVersions = true; // adds api-supported-versions header
options.ApiVersionReader = ApiVersionReader.Combine(
new UrlSegmentApiVersionReader(),
new HeaderApiVersionReader("X-Api-Version"),
new QueryStringApiVersionReader("api-version")
);
})
.AddApiExplorer(options =>
{
options.GroupNameFormat = "'v'VVV";
options.SubstituteApiVersionInUrl = true;
});
var app = builder.Build();
app.UseHttpsRedirection();
app.UseAuthorization();
app.MapControllers();
app.Run();
The ApiVersionReader.Combine call means ASP.NET Core will accept the version from a URL segment, a custom header, or a query string. If more than one is present, the URL segment takes precedence. ReportApiVersions adds api-supported-versions and api-deprecated-versions response headers so clients can discover what is available without reading documentation.
URL segment versioning is the most explicit strategy and the one most API consumers expect to see: /api/v1/products, /api/v2/products. Each major version lives in its own controller class inside a versioned namespace.
// Controllers/V1/ProductsController.cs
namespace Catalog.Api.Controllers.V1;
[ApiController]
[ApiVersion("1.0")]
[Route("api/v{version:apiVersion}/[controller]")]
public class ProductsController : ControllerBase
{
private readonly IProductService _service;
public ProductsController(IProductService service)
=> _service = service;
[HttpGet("{id:int}")]
[ProducesResponseType(typeof(ProductResponseV1), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<ProductResponseV1>> GetById(
int id, CancellationToken ct)
{
var product = await _service.GetByIdAsync(id, ct);
return product is null ? NotFound() : Ok(ProductResponseV1.From(product));
}
}
// Controllers/V2/ProductsController.cs
namespace Catalog.Api.Controllers.V2;
[ApiController]
[ApiVersion("2.0")]
[Route("api/v{version:apiVersion}/[controller]")]
public class ProductsController : ControllerBase
{
private readonly IProductService _service;
public ProductsController(IProductService service)
=> _service = service;
[HttpGet("{id:int}")]
[ProducesResponseType(typeof(ProductResponseV2), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<ProductResponseV2>> GetById(
int id, CancellationToken ct)
{
var product = await _service.GetByIdAsync(id, ct);
return product is null ? NotFound() : Ok(ProductResponseV2.From(product));
}
}
Separating each version into its own namespace keeps the folder structure self-documenting. A developer navigating to Controllers/V2/ immediately knows they are looking at the current contract.
Because ApiVersionReader.Combine is already registered, no additional controller changes are required. A client can reach the same v2 endpoint three ways.
// Three equivalent requests for the v2 endpoint:
// 1. URL segment (most explicit — preferred)
// GET /api/v2/products/42
// 2. Query string (useful for quick testing and exploration)
// GET /api/products/42?api-version=2.0
// 3. Custom header (common in B2B integrations where the URL must not change)
// GET /api/products/42
// X-Api-Version: 2.0
When none of the three is supplied and AssumeDefaultVersionWhenUnspecified is true, ASP.NET Core uses the default version defined in DefaultApiVersion, which is 1.0 in this configuration. Clients that have never heard of versioning continue to work against the v1 contract without modification.
Marking a version as deprecated does not remove it. It signals to consumers through response headers that the version has an end-of-life date and they should plan to migrate. The endpoint continues to function normally.
// Controllers/V1/ProductsController.cs — mark version 1.0 as deprecated
[ApiController]
[ApiVersion("1.0", Deprecated = true)]
[Route("api/v{version:apiVersion}/[controller]")]
public class ProductsController : ControllerBase
{
// ... existing v1 actions unchanged
}
With Deprecated = true and ReportApiVersions = true, every response to a v1 endpoint now includes both headers, giving clients a clear machine-readable signal.
// Response headers returned on any v1 endpoint call:
// api-supported-versions: 2.0
// api-deprecated-versions: 1.0
Complement the headers with a Sunset header indicating the retirement date. You can inject this via middleware or by overriding the response in a filter.
// Api/Filters/SunsetHeaderFilter.cs
public class SunsetHeaderFilter : IActionFilter
{
private static readonly DateTimeOffset V1Sunset =
new DateTimeOffset(2025, 12, 31, 0, 0, 0, TimeSpan.Zero);
public void OnActionExecuting(ActionExecutingContext context) { }
public void OnActionExecuted(ActionExecutedContext context)
{
var requestedVersion = context.HttpContext
.GetRequestedApiVersion()?.ToString();
if (requestedVersion == "1.0")
{
context.HttpContext.Response.Headers["Sunset"] =
V1Sunset.ToString("R"); // RFC 1123 date format
}
}
}
// Program.cs — register as a global filter
builder.Services.AddControllers(options =>
options.Filters.Add<SunsetHeaderFilter>());
Run the project and confirm that the correct controller action is invoked based on the version provided.
// dotnet run --project Catalog.Api
// URL segment — should return v2 response shape
// GET https://localhost:7001/api/v2/products/1
// Query string — should return v1 response shape with deprecation headers
// GET https://localhost:7001/api/products/1?api-version=1.0
// No version — should return v1 response (default) with deprecation headers
// GET https://localhost:7001/api/products/1
// Unsupported version — should return 400 with Problem Details
// GET https://localhost:7001/api/v99/products/1
When a client requests a version that no controller supports, ASP.NET Core returns a 400 Bad Request with a Problem Details body automatically. No additional error handling code is needed.
// Integration test verifying version routing
[Theory]
[InlineData("/api/v1/products/1", "1.0")]
[InlineData("/api/v2/products/1", "2.0")]
public async Task GetById_RoutesToCorrectVersion(string url, string expectedVersion)
{
await using var factory = new WebApplicationFactory<Program>();
var client = factory.CreateClient();
var response = await client.GetAsync(url);
Assert.True(response.IsSuccessStatusCode);
// Confirm the supported version header is present and correct
Assert.True(response.Headers.Contains("api-supported-versions"));
}
A versioned .NET 8 Web API needs four things: the Asp.Versioning.Mvc package, a versioning configuration that accepts the version from URL segments, headers, and query strings, controller classes organized by version namespace, and the Deprecated flag on any version that has an end-of-life date. Together these give you a versioning strategy that is transparent to consumers, easy to extend, and capable of signaling retirement without breaking anything.
This post completes the ASP.NET Core Controller API Patterns series. The patterns covered across these posts, from routing model selection through project structure, validation, error handling, and versioning, form a baseline that scales from a small internal API to a multi-team public contract.
No comments: