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.
- You renamed a field and broke five mobile clients
- You have
/v1,/v1.1,/v2,/v2-beta, and/v2-finalin 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 shapeStep 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
- 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.
- Forking the domain layer per version. Two copies of
OrderService= two copies of the bugs. Map at the edge; share the core. - No sunset policy. "v1 forever" is a gift to customers and a tax on you. Document a 12-month minimum sunset; emit
Sunset+Deprecationheaders from day one of v2. - Mixing schemes. URL and header and media type. Pick one canonical way for clients; accept the others as aliases at most.
- Treating beta and v2 as the same thing. A breaking-change preview belongs on a separate preview endpoint with its own clearly-temporary lifecycle.
- 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.

