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.

Key takeaways
  • 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.Versioning packages replaced the old Microsoft.AspNetCore.Mvc.Versioning and work with both controllers and Minimal APIs.
  • Mark old versions deprecated with a Sunset header well before you remove them — silence is how clients get surprised.

Three ways to carry a version

StrategyExampleTrade-off
URL segment/v1/ordersVisible, cacheable, easy to route — but the URL changes, which some see as a purity violation
Query string/orders?api-version=1.0Same URL, easy to default — but easy to omit accidentally
Headerapi-version: 1.0Cleanest 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

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

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

csharp
var versionSet = app.NewApiVersionSet()
    .HasApiVersion(new ApiVersion(1.0))
    .HasDeprecatedApiVersion(new ApiVersion(1.0))
    .HasApiVersion(new ApiVersion(2.0))
    .Build();
Practical tipWhen a version is deprecated, add a 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.

Need help with this in production?

astrivo helps teams design, build, and modernize systems like this. Let's talk about your context.