Problem Context

Versioning is what you do because you can't go back in time and design the API right the first time. The 2026 reality:Asp.Versioning 8.x on .NET 9 makes URL, query-string, header, and media-type versioning equally easy in code โ€” so the choice is now about ergonomics and tooling, not implementation. Public APIs trend toward URL versioning (/v2/orders) because it's debuggable and CDN-cacheable; internal APIs increasingly use header versioning to avoid URL churn.

The bigger question is "when to bump the version" โ€” i.e., what counts as breaking. The healthy answer in 2026 is the same as 2010: additive changes (new fields, new endpoints, new optional parameters) never bump the version. Removals, renames, type changes, and stricter validation always do.

๐Ÿค” Sound familiar?
  • You renamed a field and broke five mobile clients
  • You have /v1, /v1.1, /v2, /v2-beta, and /v2-final in production
  • You can't deprecate /v1because three customers won't upgrade
  • Your "non-breaking" change broke the iOS app from 2024

Pick one versioning scheme, document the contract, deprecate with sunset headers, and only bump for true breaks.

Concept Explanation

Four common schemes:

  • URL path โ€” /v1/orders. Easy, debuggable, CDN-friendly. Most public APIs use this.
  • Query string โ€” /orders?api-version=2026-04-01. Azure's favored style; works with date-based versions.
  • Custom header โ€” X-Api-Version: 2. Clean URLs but harder to test in a browser.
  • Media type (Accept) โ€” Accept: application/vnd.example.v2+json. RESTful purist's pick; awkward in CDNs.

And a fifth strategy that works everywhere: never break. Add new fields, never remove. Many APIs (GitHub, Stripe) have kept the same major version for a decade by being disciplined about additive change.


flowchart LR
    C["Client v1"] -->|GET /v1/orders/123| API["API"]
    C -.->|Sunset: Wed, 01 Jan 2027<br/>Deprecation: true| C
    C2["Client v2"] -->|GET /v2/orders/123| API
    API --> H1["v1 handler<br/>(maps to v2 internally)"]
    API --> H2["v2 handler"]

    style API fill:#0078D4,color:#fff,stroke:#005a9e

Implementation

Step 1: Wire up Asp.Versioning on .NET 9

// dotnet add package Asp.Versioning.Http
// dotnet add package Asp.Versioning.Mvc.ApiExplorer

builder.Services.AddApiVersioning(o =>
{
    o.DefaultApiVersion = new ApiVersion(1, 0);
    o.AssumeDefaultVersionWhenUnspecified = true;
    o.ReportApiVersions = true;            // adds api-supported-versions header
    o.ApiVersionReader = ApiVersionReader.Combine(
        new UrlSegmentApiVersionReader(),
        new HeaderApiVersionReader("X-Api-Version"),
        new MediaTypeApiVersionReader("v"));
}).AddApiExplorer(o =>
{
    o.GroupNameFormat = "'v'VVV";          // for OpenAPI grouping
    o.SubstituteApiVersionInUrl = true;
});

Step 2: Versioned endpoints (Minimal API)

var versionSet = app.NewApiVersionSet()
    .HasApiVersion(new ApiVersion(1, 0))
    .HasApiVersion(new ApiVersion(2, 0))
    .ReportApiVersions()
    .Build();

var v1 = app.MapGroup("/v{version:apiVersion}/orders")
    .WithApiVersionSet(versionSet)
    .MapToApiVersion(1, 0);

var v2 = app.MapGroup("/v{version:apiVersion}/orders")
    .WithApiVersionSet(versionSet)
    .MapToApiVersion(2, 0);

v1.MapGet("/{id:long}", (long id) => GetOrderV1(id));
v2.MapGet("/{id:long}", (long id) => GetOrderV2(id));   // new shape

Step 3: Internal mapping (don't fork the domain)

// Anti-pattern: two copies of OrderService.
// Pattern: ONE domain, multiple wire mappers.

public record OrderV1Dto(long Id, string Customer, decimal Total);
public record OrderV2Dto(long Id, CustomerDto Customer, MoneyDto Total, DateTimeOffset PlacedAt);

static OrderV1Dto MapV1(Order o) =>
    new(o.Id, o.Customer.DisplayName, o.TotalCents / 100m);

static OrderV2Dto MapV2(Order o) =>
    new(o.Id,
        new CustomerDto(o.Customer.Id, o.Customer.DisplayName),
        new MoneyDto(o.TotalCents, "USD"),
        o.PlacedAt);
// One domain. Two views. Easy to add v3 next year.

Step 4: Deprecation + Sunset headers (RFC 8594, RFC 9745)

v1.MapGet("/{id:long}", (long id, HttpContext ctx) =>
{
    ctx.Response.Headers["Deprecation"] = "true";
    ctx.Response.Headers["Sunset"]      = "Wed, 01 Jan 2027 00:00:00 GMT";
    ctx.Response.Headers["Link"]        =
        "<https://docs.example.com/migrate-v2>; rel=\"deprecation\"";
    return GetOrderV1(id);
});
// Clients (and good HTTP libraries) surface these to the developer.

Step 5: Date-based versions (Stripe / Azure style)

// Azure SDK / ARM convention: ?api-version=2026-04-01
// Each release date is an immutable snapshot of behavior.
// Internally: keep one current implementation + a thin compatibility layer
// that adapts the request/response to the requested date.

builder.Services.AddApiVersioning(o =>
{
    o.ApiVersionReader = new QueryStringApiVersionReader("api-version");
    o.DefaultApiVersion = new ApiVersion(new DateOnly(2026, 4, 1));
});

Step 6: Surface versions in OpenAPI

// .NET 9 built-in OpenAPI + Asp.Versioning.Mvc.ApiExplorer
builder.Services.AddOpenApi("v1");
builder.Services.AddOpenApi("v2");

app.MapOpenApi("/openapi/{documentName}.json");

// Result: /openapi/v1.json and /openapi/v2.json โ€” two clean specs,
// each consumable by its own SDK / docs site.

Common Pitfalls

  1. Bumping the version for additive changes. Adding an optional field is not breaking. Bumping for it just trains clients to upgrade for nothing and forces you to maintain N versions instead of one.
  2. Forking the domain layer per version. Two copies of OrderService = two copies of the bugs. Map at the edge; share the core.
  3. No sunset policy. "v1 forever" is a gift to customers and a tax on you. Document a 12-month minimum sunset; emit Sunset + Deprecation headers from day one of v2.
  4. Mixing schemes. URL and header and media type. Pick one canonical way for clients; accept the others as aliases at most.
  5. Treating beta and v2 as the same thing. A breaking-change preview belongs on a separate preview endpoint with its own clearly-temporary lifecycle.
  6. Versioning everything.Internal microservices don't need versioning if you can deploy clients and servers together. Reserve the cost for boundaries you don't control.

Practical Takeaways

  • Default to additive evolution. Most public APIs go a decade without a v2.
  • When you must break: pick URL versioning for public APIs, header for internal ones.
  • One domain, multiple wire mappers. Never fork the business logic.
  • Emit Deprecation + Sunsetheaders (RFC 9745). They're the polite way to retire a version.
  • Generate per-version OpenAPI docs; ship per-version SDKs.
  • Date-based versions work well when you ship behavioral changes, not just shape changes.
  • Document the contract in writing (additive vs breaking) so "is this breaking?" isn't a Slack thread every PR.