API Versioning Strategies in .NET

10 min readMarch 9, 2026
API versioning .NETAsp.Versioning.NET API version strategyAPI deprecationREST API versioningMinimal API versioningAPI backward compatibilitySwagger versioning

# 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/products

The 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.0

Keeps 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.0

Keeps 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+json

The 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:

csharp
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.Http

Basic Configuration

csharp
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

csharp
[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:

csharp
[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

csharp
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:

csharp
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)

  • Adding new optional fields to a response
  • Adding a new endpoint
  • Adding optional query parameters
  • Relaxing a validation constraint
  • Breaking (requires a new version)

  • Removing or renaming a field
  • Changing a field's data type
  • Altering response structure (e.g., wrapping in a new envelope)
  • Changing error response format
  • Making a previously optional field required
  • Changing HTTP method or status code semantics
  • 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:

    csharp
    [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:

    csharp
    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):

    csharp
    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:

    csharp
    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`:

    csharp
    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

  • **Versioning too aggressively** -- creating a new version for every minor change fragments the API surface and multiplies maintenance cost. Reserve new versions for genuine breaking changes.
  • **No deprecation timeline** -- if deprecated versions linger forever, you maintain every version you have ever shipped. Set explicit sunset dates.
  • **Inconsistent versioning across endpoints** -- versioning individual endpoints rather than the whole API creates a confusing matrix that is nearly impossible to document.
  • **Forgetting client-side impact** -- SDKs, client libraries, and integration tests all need to track which version they target.
  • **Coupling versions to deployment cadence** -- API versions represent contract changes, not release cycles.
  • **Ignoring version discovery** -- if clients cannot programmatically find available versions, they will inevitably call the wrong one.
  • 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

    Have a Flutter Project?

    I build high-performance Flutter applications for iOS, Android, and web.

    Get in Touch