Every public API eventually needs a breaking change. The question isn't whether to version — it's how, and the answer has real consequences for caching, client tooling, and how much pain a breaking change actually causes downstream.
- URL segment versioning (
/v1/orders) is the most visible and cache-friendly option — our default for public APIs. - Header-based versioning keeps URLs stable, which fits internal APIs where you control every client.
- The
Asp.Versioningpackages replaced the oldMicrosoft.AspNetCore.Mvc.Versioningand work with both controllers and Minimal APIs. - Mark old versions deprecated with a
Sunsetheader well before you remove them — silence is how clients get surprised.
Three ways to carry a version
| Strategy | Example | Trade-off |
|---|---|---|
| URL segment | /v1/orders | Visible, cacheable, easy to route — but the URL changes, which some see as a purity violation |
| Query string | /orders?api-version=1.0 | Same URL, easy to default — but easy to omit accidentally |
| Header | api-version: 1.0 | Cleanest URLs — but invisible in browser address bars and harder to test casually |
We default to URL segment versioning for anything public: it's the version scheme with the least chance of a client silently using the wrong one, and it's trivially cacheable at the CDN layer.
Setting it up with Asp.Versioning
builder.Services.AddApiVersioning(options =>
{
options.DefaultApiVersion = new ApiVersion(1.0);
options.AssumeDefaultVersionWhenUnspecified = true;
options.ReportApiVersions = true;
options.ApiVersionReader = new UrlSegmentApiVersionReader();
})
.AddApiExplorer(options =>
{
options.GroupNameFormat = "'v'VVV";
options.SubstituteApiVersionInUrl = true;
});
Versioning Minimal API route groups
Minimal APIs version through a versioned route group rather than attributes on a controller:
var versionSet = app.NewApiVersionSet()
.HasApiVersion(new ApiVersion(1.0))
.HasApiVersion(new ApiVersion(2.0))
.ReportApiVersions()
.Build();
var orders = app.MapGroup("/api/v{version:apiVersion}/orders")
.WithApiVersionSet(versionSet);
orders.MapGet("/{id:guid}", GetOrderV1)
.HasApiVersion(1.0);
orders.MapGet("/{id:guid}", GetOrderV2)
.HasApiVersion(2.0);
Both handlers live at the same route template; the library picks the right one based on the version segment in the URL. This is what makes it practical to run v1 and v2 side by side instead of duplicating an entire application.
Deprecating a version without breaking clients overnight
Mark a version deprecated well ahead of removing it, and surface it in the response so client teams have a machine-readable signal, not just a changelog entry nobody reads.
var versionSet = app.NewApiVersionSet()
.HasApiVersion(new ApiVersion(1.0))
.HasDeprecatedApiVersion(new ApiVersion(1.0))
.HasApiVersion(new ApiVersion(2.0))
.Build();
Sunset response header with the removal date (RFC 8594). It's a standard, machine-readable way to tell client tooling “this will stop working on this date” instead of relying on someone reading release notes.Internal APIs: consider header-based instead
If you own every client — service-to-service calls inside your own system — header-based versioning keeps URLs stable and avoids route duplication. The trade-off (harder to test with a browser, invisible in URLs) matters much less when the only clients are other services on your team.