n tooling compatibility.
Core Solution
Implementing versioning in .NET requires a contract-first approach using the official Microsoft.AspNetCore.Mvc.Versioning package. The framework provides declarative version resolution, deprecation signaling, and OpenAPI integration without custom middleware.
Step 1: Package Installation & Service Registration
Install the official package:
dotnet add package Microsoft.AspNetCore.Mvc.Versioning
dotnet add package Microsoft.AspNetCore.Mvc.Versioning.ApiExplorer
Register versioning services in Program.cs:
builder.Services.AddApiVersioning(options =>
{
options.DefaultApiVersion = new ApiVersion(1, 0);
options.AssumeDefaultVersionWhenUnspecified = true;
options.ReportApiVersions = true;
options.ApiVersionReader = ApiVersionReader.Combine(
new HeaderApiVersionReader("Api-Version"),
new QueryStringApiVersionReader("api-version")
);
});
builder.Services.AddVersionedApiExplorer(options =>
{
options.GroupNameFormat = "'v'VVV";
options.SubstituteApiVersionInUrl = true;
});
Architecture Decision: Combining header and query string readers provides flexibility. Header-based resolution is preferred for internal services to maintain clean URIs. Query string fallback accommodates legacy clients or browser-based testing tools that cannot inject custom headers.
Step 2: Controller & Action Versioning
Apply version attributes to controllers or specific actions:
[ApiVersion("1.0")]
[ApiVersion("2.0")]
[Route("api/[controller]")]
[ApiController]
public class ProductsController : ControllerBase
{
[HttpGet]
[MapToApiVersion("1.0")]
public IActionResult GetV1() => Ok(new { Id = 1, Name = "Legacy Product" });
[HttpGet]
[MapToApiVersion("2.0")]
public IActionResult GetV2() => Ok(new { Id = 1, Name = "Legacy Product", Category = "Electronics" });
}
The framework resolves the target action based on the incoming version header or query parameter. If multiple versions match, the highest available version is selected unless constrained.
Step 3: Deprecation & Sunset Handling
Mark obsolete versions explicitly:
[ApiVersion("1.0", Deprecated = true)]
[ApiVersion("2.0")]
[Route("api/[controller]")]
[ApiController]
public class ProductsController : ControllerBase { ... }
When Deprecated = true is set, the framework automatically includes Sunset and Deprecation headers in responses. Configure sunset timing globally:
options.ApiVersionReader = new HeaderApiVersionReader("Api-Version");
options.UseApiBehavior = true;
Step 4: OpenAPI Integration
Generate versioned documentation using Swashbuckle or NSwag:
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(options =>
{
var provider = builder.Services.BuildServiceProvider().GetRequiredService<IApiVersionDescriptionProvider>();
foreach (var description in provider.ApiVersionDescriptions)
{
options.SwaggerDoc(description.GroupName, new OpenApiInfo
{
Title = $"Product API {description.ApiVersion}",
Version = description.ApiVersion.ToString(),
Description = description.IsDeprecated ? "This version is deprecated." : ""
});
}
});
This generates separate Swagger UI endpoints (/swagger/v1.0/swagger.json, /swagger/v2.0/swagger.json) aligned with resolved versions.
Architecture Rationale
- Declarative over Imperative: Attributes keep versioning logic close to the contract definition, avoiding scattered routing middleware.
- Explicit Resolution:
AssumeDefaultVersionWhenUnspecified = true prevents 400 errors for legacy clients while maintaining strict version tracking.
- Version-Aware Documentation:
IApiVersionDescriptionProvider ensures OpenAPI specs reflect actual resolved versions, enabling accurate client SDK generation.
Pitfall Guide
1. Versioning Implementation Details Instead of Contracts
Developers frequently version controllers based on internal refactoring (e.g., switching from EF Core to Dapper) rather than consumer-facing contract changes. Version boundaries should only exist when request/response shapes, authentication flows, or error semantics change. Internal implementation changes belong behind the same version contract.
2. Silent Breaking Changes
Modifying a response DTO by removing a field, changing a type, or altering nullability without incrementing the version causes client deserialization failures. Always maintain backward compatibility within a version. Additive changes are safe; subtractive or type-changing changes require a new version.
3. Mixing Versioning Strategies in the Same Surface
Combining URL path versioning (/api/v1/) with header-based resolution (Api-Version: 2) in the same API surface creates routing ambiguity and breaks OpenAPI path generation. Choose one primary transport per API group and enforce it through middleware or gateway policies.
4. Ignoring Deprecation Lifecycle
Removing a version without signaling deprecation violates HTTP semantics and breaks automated client retries. Always set Deprecated = true, configure Sunset headers with a migration window, and monitor usage metrics before removal. Production systems should return 410 Gone only after the sunset period expires.
5. Over-Relying on URL Path Versioning for Microservices
URL path versioning fragments cache keys, increases CDN costs, and complicates service mesh routing tables. Internal services should prefer header-based versioning to preserve resource identity and enable efficient caching at the gateway layer.
6. Treating Versioning as a Routing Problem
Versioning is a contract negotiation mechanism, not a URL pattern. Routing configuration should reflect resolved versions, not drive them. When versioning logic lives in route templates, the framework cannot enforce deprecation policies, report version usage, or generate accurate OpenAPI specs.
7. Neglecting Version-Aware Monitoring
Without version-specific telemetry, teams cannot measure migration progress or detect client stagnation. Instrument requests with Api-Version tags in Application Insights or OpenTelemetry. Track version distribution, error rates per version, and sunset compliance to drive migration automation.
Best Practices from Production:
- Enforce contract testing (Pact, WireMock) per version before deployment
- Use API gateways to enforce version headers and reject unsupported versions at the edge
- Automate client SDK generation from versioned OpenAPI specs
- Maintain a version migration dashboard tied to telemetry
- Deprecate, don't delete: keep obsolete versions operational during migration windows
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Public B2C API | URL Path (/api/v1/) | Discoverable, browser-friendly, SEO-compatible, SDK generation aligns with path structure | Medium (CDN cache fragmentation) |
| Internal Microservices | Header (Api-Version: 1) | Preserves resource identity, enables aggressive caching, reduces routing complexity | Low (minimal infrastructure overhead) |
| B2B Partner Integration | Query String (?api-version=1) | Easy to test in browsers, backward-compatible with legacy HTTP clients, simple gateway passthrough | Low (negligible, but complicates OpenAPI paths) |
| Rapid Prototyping / MVP | URL Path with assumed default | Fastest to implement, aligns with developer expectations, easy to migrate to header later | Low (technical debt if not formalized) |
Configuration Template
// Program.cs
using Microsoft.AspNetCore.Mvc.Versioning;
using Microsoft.OpenApi.Models;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.AddApiVersioning(options =>
{
options.DefaultApiVersion = new ApiVersion(1, 0);
options.AssumeDefaultVersionWhenUnspecified = true;
options.ReportApiVersions = true;
options.ApiVersionReader = ApiVersionReader.Combine(
new HeaderApiVersionReader("Api-Version"),
new QueryStringApiVersionReader("api-version")
);
});
builder.Services.AddVersionedApiExplorer(options =>
{
options.GroupNameFormat = "'v'VVV";
options.SubstituteApiVersionInUrl = true;
});
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen(options =>
{
var provider = builder.Services.BuildServiceProvider().GetRequiredService<IApiVersionDescriptionProvider>();
foreach (var description in provider.ApiVersionDescriptions)
{
options.SwaggerDoc(description.GroupName, new OpenApiInfo
{
Title = $"Sample API {description.ApiVersion}",
Version = description.ApiVersion.ToString(),
Description = description.IsDeprecated ? "Deprecated. Migrate to latest version." : ""
});
}
});
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI(options =>
{
var provider = app.Services.GetRequiredService<IApiVersionDescriptionProvider>();
foreach (var description in provider.ApiVersionDescriptions)
{
options.SwaggerEndpoint($"/swagger/{description.GroupName}/swagger.json", description.GroupName.ToUpperInvariant());
}
});
}
app.UseHttpsRedirection();
app.UseAuthorization();
app.MapControllers();
app.Run();
Quick Start Guide
- Install packages:
dotnet add package Microsoft.AspNetCore.Mvc.Versioning Microsoft.AspNetCore.Mvc.Versioning.ApiExplorer
- Add
AddApiVersioning() and AddVersionedApiExplorer() to service registration with header/query fallback
- Decorate controllers with
[ApiVersion("1.0")] and actions with [MapToApiVersion("1.0")]
- Run the application and test with
curl -H "Api-Version: 1.0" https://localhost:5001/api/products
- Verify version headers in response:
api-supported-versions: 1.0, 2.0 and api-deprecated-versions: (empty unless marked deprecated)