API Versioning Strategies in .NET
# API Versioning Strategies in .NET
APIs evolve. Fields get renamed, endpoints get restructured, response shapes change. Without a deliberate versioning strategy, every change risks breaking the clients that depend on you. In APIs consumed by multiple clients -- mobile apps, partner integrations, internal frontends -- versioning is the contract that lets you move forward without dragging everyone else backward.
Why API Versioning Matters
When you ship an API, consumers write code against your response shapes, status codes, and field names. Change any of those and you break that promise. Without versioning, teams either freeze their API or ship silent breakage. Both erode trust.
Versioning Approaches Compared
URL Path Versioning
GET /api/v1/products
GET /api/v2/productsThe most visible and widely adopted approach. Easy to discover, route, and cache. API gateways can route on path segments without custom logic. The downside is URL pollution.
Query String Versioning
GET /api/products?api-version=1.0
GET /api/products?api-version=2.0Keeps URLs cleaner and is easy to implement, but can be overlooked in documentation. Caching proxies may ignore query parameters by default.
Header Versioning
GET /api/products
X-Api-Version: 1.0Keeps the URL clean and separates versioning from resource identification. Works well for internal APIs. The tradeoff is reduced discoverability.
Media Type Versioning
GET /api/products
Accept: application/vnd.myapp.v2+jsonThe most RESTful approach in theory but rarely adopted in practice due to weaker tooling support and added cognitive overhead.
Setting Up Asp.Versioning
The `Asp.Versioning` library (formerly `Microsoft.AspNetCore.Mvc.Versioning`) is the standard for .NET API versioning:
class=class="code-string">"code-comment">// For controllers
dotnet add package Asp.Versioning.Mvc
dotnet add package Asp.Versioning.Mvc.ApiExplorer
class=class="code-string">"code-comment">// For Minimal APIs
dotnet add package Asp.Versioning.HttpBasic Configuration
builder.Services.AddApiVersioning(options =>
{
options.DefaultApiVersion = new ApiVersion(class="code-number">1, class="code-number">0);
options.AssumeDefaultVersionWhenUnspecified = true;
options.ReportApiVersions = true;
options.ApiVersionReader = ApiVersionReader.Combine(
new UrlSegmentApiVersionReader(),
new HeaderApiVersionReader(class="code-string">"X-Api-Version"),
new QueryStringApiVersionReader(class="code-string">"api-version")
);
})
.AddApiExplorer(options =>
{
options.GroupNameFormat = class="code-string">"class="code-string">'v'VVV";
options.SubstituteApiVersionInUrl = true;
});`ReportApiVersions = true` adds `api-supported-versions` and `api-deprecated-versions` headers to every response.
Versioning with Controllers
[ApiController]
[Route(class="code-string">"api/v{version:apiVersion}/[controller]")]
[ApiVersion(class="code-string">"class="code-number">1.0")]
public class ProductsController : ControllerBase
{
[HttpGet]
public IActionResult GetAll()
{
return Ok(new[]
{
new { Id = class="code-number">1, Name = class="code-string">"Widget", Price = class="code-number">9.99m }
});
}
}
[ApiController]
[Route(class="code-string">"api/v{version:apiVersion}/[controller]")]
[ApiVersion(class="code-string">"class="code-number">2.0")]
public class ProductsV2Controller : ControllerBase
{
[HttpGet]
public IActionResult GetAll()
{
return Ok(new[]
{
new { Id = class="code-number">1, Name = class="code-string">"Widget", UnitPrice = class="code-number">9.99m, Currency = class="code-string">"USD" }
});
}
}Multiple versions can share a controller:
[ApiController]
[Route(class="code-string">"api/v{version:apiVersion}/[controller]")]
[ApiVersion(class="code-string">"class="code-number">1.0")]
[ApiVersion(class="code-string">"class="code-number">2.0")]
public class CustomersController : ControllerBase
{
[HttpGet]
[MapToApiVersion(class="code-string">"class="code-number">1.0")]
public IActionResult GetV1() => Ok(new { Name = class="code-string">"Acme" });
[HttpGet]
[MapToApiVersion(class="code-string">"class="code-number">2.0")]
public IActionResult GetV2() => Ok(new { Name = class="code-string">"Acme", Tier = class="code-string">"Enterprise" });
}Versioning with Minimal APIs
var versionSet = app.NewApiVersionSet()
.HasApiVersion(new ApiVersion(class="code-number">1, class="code-number">0))
.HasApiVersion(new ApiVersion(class="code-number">2, class="code-number">0))
.ReportApiVersions()
.Build();
var v1 = app.MapGroup(class="code-string">"api/v{version:apiVersion}/products")
.WithApiVersionSet(versionSet)
.MapToApiVersion(new ApiVersion(class="code-number">1, class="code-number">0));
var v2 = app.MapGroup(class="code-string">"api/v{version:apiVersion}/products")
.WithApiVersionSet(versionSet)
.MapToApiVersion(new ApiVersion(class="code-number">2, class="code-number">0));
v1.MapGet(class="code-string">"/", () => Results.Ok(new[]
{
new { Id = class="code-number">1, Name = class="code-string">"Widget", Price = class="code-number">9.99m }
}));
v2.MapGet(class="code-string">"/", () => Results.Ok(new[]
{
new { Id = class="code-number">1, Name = class="code-string">"Widget", UnitPrice = class="code-number">9.99m, Currency = class="code-string">"USD" }
}));As the version count grows, consider extracting route registrations into dedicated extension methods.
Version Negotiation and Default Versions
Setting `AssumeDefaultVersionWhenUnspecified = true` means unversioned requests fall through to your default version. This is convenient but can mask issues -- a client might not realize they are hitting an older version. A safer production pattern:
options.AssumeDefaultVersionWhenUnspecified = false;This returns `400 Bad Request` for unsupported versions, forcing consumers to be intentional about their target version.
Breaking vs Non-Breaking Changes
Non-Breaking (safe to add without versioning)
Breaking (requires a new version)
In APIs consumed by multiple clients, the gray area causes more damage than obvious breaks. A field changing from `string` to `string | null` is technically additive, but it crashes consumers that do not handle nulls. When in doubt, version it.
Deprecation Strategy and Sunset Headers
You also need a plan for retiring old versions. Mark versions as deprecated:
[ApiVersion(class="code-string">"class="code-number">1.0", Deprecated = true)]
[ApiVersion(class="code-string">"class="code-number">2.0")]
public class ProductsController : ControllerBase { }Or in Minimal APIs:
var versionSet = app.NewApiVersionSet()
.HasDeprecatedApiVersion(new ApiVersion(class="code-number">1, class="code-number">0))
.HasApiVersion(new ApiVersion(class="code-number">2, class="code-number">0))
.Build();Deprecated versions continue working but include a `api-deprecated-versions: 1.0` header. Complement with the `Sunset` header (RFC 8594):
app.Use(async (context, next) =>
{
await next();
var apiVersion = context.GetRequestedApiVersion();
if (apiVersion?.MajorVersion == class="code-number">1)
{
context.Response.Headers[class="code-string">"Sunset"] = class="code-string">"Sat, class="code-number">01 Nov class="code-number">2025 class="code-number">00:class="code-number">00:class="code-number">00 GMT";
context.Response.Headers[class="code-string">"Link"] =
class="code-string">"</api/v2/products>; rel=\"successor-version\"";
}
});This gives clients a machine-readable deadline and a pointer to the replacement.
OpenAPI Documentation for Versioned APIs
Configure Swagger/OpenAPI to generate separate docs per version:
builder.Services.AddSwaggerGen(options =>
{
options.SwaggerDoc(class="code-string">"v1", new OpenApiInfo
{
Title = class="code-string">"Products API",
Version = class="code-string">"v1",
Description = class="code-string">"Original product endpoints"
});
options.SwaggerDoc(class="code-string">"v2", new OpenApiInfo
{
Title = class="code-string">"Products API",
Version = class="code-string">"v2",
Description = class="code-string">"Enhanced product endpoints with currency support"
});
});
class=class="code-string">"code-comment">// In the middleware pipeline
app.UseSwagger();
app.UseSwaggerUI(options =>
{
options.SwaggerEndpoint(class="code-string">"/swagger/v1/swagger.json", class="code-string">"Products API v1");
options.SwaggerEndpoint(class="code-string">"/swagger/v2/swagger.json", class="code-string">"Products API v2");
});For auto-discovery, use `IConfigureOptions
public class ConfigureSwaggerOptions : IConfigureOptions<SwaggerGenOptions>
{
private readonly IApiVersionDescriptionProvider _provider;
public ConfigureSwaggerOptions(IApiVersionDescriptionProvider provider)
=> _provider = provider;
public void Configure(SwaggerGenOptions options)
{
foreach (var description in _provider.ApiVersionDescriptions)
{
options.SwaggerDoc(description.GroupName, new OpenApiInfo
{
Title = class="code-string">"Products API",
Version = description.ApiVersion.ToString(),
Description = description.IsDeprecated
? class="code-string">"This version has been deprecated."
: null
});
}
}
}Adding a new version now automatically generates its OpenAPI document.
Common API Versioning Mistakes
Conclusion
API versioning is about trust. The technical implementation matters less than the commitment to communicate changes clearly and retire versions responsibly. Pick a scheme that fits your ecosystem, automate documentation, and enforce sunset dates.
If you are designing a versioning strategy for your .NET APIs, I would be happy to help you find the right approach.
Related Articles
Building RESTful APIs with ASP.NET Core
Learn the fundamentals of building production-ready REST APIs with ASP.NET Core. Controllers, routing, and best practices.
.NET Minimal APIs: Lightweight and Fast Endpoints
Create fast and lightweight endpoints with .NET Minimal APIs. Controller-free approach and use cases.
ASP.NET Core Middleware Pipeline: A Deep Dive
Deep dive into ASP.NET Core middleware pipeline. Custom middleware, ordering, exception handling, and rate limiting.
Have a Flutter Project?
I build high-performance Flutter applications for iOS, Android, and web.
Get in Touch