API Versioning Should Be Your Last Resort in ASP.NET Core
Learn why API versioning in ASP.NET Core should be your last resort, how to make backward-compatible changes first, and how to version cleanly when you must.
Imagine your family runs a small tiffin (lunchbox) service in your street. Every day people order the same dabba. One morning you decide to add a free piece of fruit in every box. Nobody complains. The fruit is a bonus. Old customers still get their roti and sabzi, plus something extra. You did not need to print a brand-new menu or rename your shop. You just added something.
Now imagine instead you decide to stop putting rice in the box and put noodles instead. That is a different story. Regular customers open the box and panic. "Where is my rice?" Some of them will stop ordering. For a change like this, you might need a whole new menu, maybe even a new shop name, so people know what they are getting.
This little story is the heart of today's topic. In an API, adding things is usually safe. Removing or changing things is what hurts people. And API versioning is the new menu and new shop name. It is powerful, but it is heavy. You should reach for it only when a gentle, additive change is truly impossible. In short: versioning should be your last resort, not your first move.
Let us walk through why, in simple steps, with real ASP.NET Core code.
What we mean by "an API contract"
When other programs talk to your API, they trust a quiet promise. They expect certain URLs to exist, certain fields to be present, and certain types to stay the same. This promise is called the contract.
A client app written six months ago does not change when you deploy. It keeps sending the same requests and reading the same fields. If you keep your promise, that old client keeps working. If you break the promise, the old client breaks, even though nobody touched its code.
So the real question is never "should I version?" first. The real question is: "Is this change safe, or does it break the promise?" Only when it breaks the promise do we even talk about versioning.
Safe changes vs breaking changes
Here is a simple table you can keep on your wall. It sorts common changes into "safe" (additive) and "breaking".
| Change you want to make | Safe or breaking? | Why |
|---|---|---|
| Add a new field to a response | Safe | Old clients just ignore unknown fields |
| Add a new optional query parameter | Safe | Old clients do not send it, default is used |
| Add a brand-new endpoint | Safe | Old clients never call it |
| Make a required field optional | Usually safe | Old clients still send it, that is fine |
| Remove a field from a response | Breaking | A client reading that field gets null or crashes |
| Rename a field | Breaking | Same as removing the old name |
| Change a field's type (string to number) | Breaking | Parsing fails on the client |
| Make an optional field required | Breaking | Old requests are now rejected |
| Change the meaning of a value | Breaking | Client logic silently does the wrong thing |
Look at the left column. Most everyday work is in the "safe" rows. That is great news. It means most of the time you can ship improvements without any version at all.
There is one honest warning, though. "Safe" assumes well-behaved clients. If some client strictly validates the response and rejects any unknown field, even adding a field can break it. This actually happens in real systems. So know your clients. But for normal JSON clients, additive changes are safe.
The decision before you version
Steps
Want a change
You have a new need
Is it additive?
Add, do not remove or change
Ship additively
No version needed
Must break contract
Removing or changing shape
Version
Last resort, do it cleanly
Why versioning is heavy
Versioning sounds clean on paper: "just make a /v2". In real life it brings a lot of weight that you carry for years.
- You keep old code alive. As long as someone calls v1, you must maintain v1. Bugs in v1 still need fixing. Security patches still apply.
- Twice the testing. Every test now runs against v1 and v2. Add v3 and it grows again.
- Confused clients. Teams ask "which version do we use?" Docs get longer. Support tickets grow.
- Drift. Over time v1 and v2 behave a little differently in subtle ways. These differences become traps.
Here is the cost growing over time when you reach for versions too quickly.
None of this means versioning is bad. It means versioning is a real cost. You pay it for years. So you only want to pay it when you truly must.
The pattern that saves you: expand, migrate, contract
When you think you need a breaking change, try this three-step dance first. It is often called expand, migrate, contract (sometimes "parallel change"). It lets you evolve without ever breaking the contract.
- Expand. Add the new thing next to the old thing. Both exist together.
- Migrate. Tell clients to move to the new thing. Watch your logs to see who still uses the old one.
- Contract. Only after everyone has moved, and the deadline passes, remove the old thing.
Expand, migrate, contract
Steps
Expand
Add new field beside old
Migrate
Move clients, watch logs
Contract
Remove old after deadline
Say you want to rename name to fullName. A rename is breaking. But with expand-migrate-contract you avoid the break:
// EXPAND: return BOTH the old field and the new field.
public record CustomerDto
{
// Old field kept for existing clients.
public string Name { get; init; } = "";
// New field added beside it. Old clients ignore it.
public string FullName { get; init; } = "";
}
// In your mapping code, fill both from the same source.
var dto = new CustomerDto
{
Name = customer.FullName, // keep old clients happy
FullName = customer.FullName // new clients use this
};Both fields ship at once. No client breaks. Then you nudge clients to read fullName. Months later, when logs show nobody depends on name, you remove it. You did a "rename" without ever breaking the contract, and without a single new version.
When versioning really is the right call
Sometimes a change is so deep that expand-migrate-contract gets ugly. A few honest examples:
- You are splitting one big resource into several smaller ones, and the whole response shape changes.
- A field's meaning is changing in a way that cannot live side by side with the old meaning.
- A legal or security rule forces you to remove data that some clients still read.
- You are redesigning authentication or core routing for a public API that thousands of outside developers use.
In these cases, carrying both shapes in one response would create a confusing, bloated mess. A clean version line is kinder to everyone. This is the last resort, and now it is the right tool.
Doing versioning cleanly in ASP.NET Core
When you do version, use the official library family from the dotnet/aspnet-api-versioning project. For controllers install Asp.Versioning.Mvc. For minimal APIs install Asp.Versioning.Http. (You may see the old name Microsoft.AspNetCore.Mvc.Versioning in older guides. It is the same project, just earlier branding.)
First, set it up in Program.cs:
using Asp.Versioning;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddApiVersioning(options =>
{
// If a client does not ask for a version, use this one.
options.DefaultApiVersion = new ApiVersion(1, 0);
options.AssumeDefaultVersionWhenUnspecified = true;
// Tell clients which versions exist via response headers.
// They will see "api-supported-versions" and "api-deprecated-versions".
options.ReportApiVersions = true;
})
.AddMvc(); // use .AddApiExplorer() too if you generate OpenAPI docs
var app = builder.Build();
app.MapControllers();
app.Run();Now you can run two versions of the same controller side by side. Notice v1 is marked deprecated, so the response tells clients it is going away.
using Asp.Versioning;
using Microsoft.AspNetCore.Mvc;
[ApiController]
[ApiVersion(1.0, Deprecated = true)] // old shape, on its way out
[ApiVersion(2.0)] // new shape, the one to use
[Route("api/v{version:apiVersion}/orders")]
public class OrdersController : ControllerBase
{
[HttpGet("{id:int}")]
[MapToApiVersion(1.0)]
public IActionResult GetV1(int id) =>
Ok(new { id, customer = "Asha", total = 250 });
[HttpGet("{id:int}")]
[MapToApiVersion(2.0)]
public IActionResult GetV2(int id) =>
Ok(new
{
id,
customer = new { fullName = "Asha Rao" }, // reshaped
amount = new { value = 250, currency = "INR" }
});
}Here the URL carries the version, like /api/v2/orders/5. This is the clearest style for public APIs because a human can read the URL and instantly know which version they are hitting.
Picking one versioning style
There are several places to put the version. Pick one and stay with it. Mixing styles in the same API is a real anti-pattern that confuses clients and bloats your docs.
| Style | What it looks like | Best for |
|---|---|---|
| URL path | /api/v2/orders | Public APIs, easy to read and test |
| Query string | /api/orders?api-version=2.0 | Quick demos, simple internal tools |
| Header | X-Api-Version: 2.0 | Internal service-to-service calls |
| Media type | Accept: application/json;v=2.0 | Strict REST / HATEOAS designs |
Below is how a request flows once versioning is on. The library reads the version, then routes to the matching action.
The library reads the requested version, picks the matching action, and adds helpful headers so the client knows which versions are supported and which are deprecated.
Tell clients clearly when a version is going away
Marking a version Deprecated = true is polite, but the kind thing is to also tell clients when the old version disappears. Modern ASP.NET Core versioning supports the Sunset header (and a deprecation policy) so clients get a clear, machine-readable date.
A good deprecation flow looks like this:
Retiring a version with care
Steps
Mark deprecated
Set Deprecated = true
Announce sunset date
Send Sunset header + docs
Monitor usage
Watch who still calls v1
Remove after date
Delete only when traffic is gone
You can configure a deprecation policy with an effective date and a link to your migration guide:
builder.Services.AddApiVersioning()
.AddApiExplorer(options =>
{
// Announce that version 1.0 is deprecated, with a date and a link.
options.Policies.Deprecate(1.0)
.Effective(new DateTimeOffset(2026, 12, 31, 0, 0, 0, TimeSpan.Zero))
.Link("https://example.com/api/migration-guide")
.Title("API v1 Deprecation Policy")
.Type("text/html");
});With ReportApiVersions = true and this policy, your responses carry headers like api-supported-versions, api-deprecated-versions, and a Sunset date. Now nobody is surprised. The shop gave fair notice before changing the menu.
A quick note on tools and licensing
If your versioning work touches messaging or request pipelines, you may have heard of libraries like MediatR and MassTransit. Be aware these are now commercially licensed for many uses. They are not required for API versioning at all. The Asp.Versioning.* packages handle versioning on their own and remain the standard, freely available choice. So you can add clean versioning without pulling in any paid dependency.
This whole article assumes .NET 10, which is the current LTS release, with C# 14. Everything above works on that stack. (C# 15 union types are arriving in the .NET 11 preview line, but you do not need them here.)
A realistic example: walking the safe road first
Let us tie it together with a small story. Your orders API returns a total. A new feature wants to show tax separately. A junior teammate says, "Let's make /v2 and change total to mean pre-tax." Stop. That changes the meaning of an existing field. It is breaking, and it does not even need to be.
The safe road:
public record OrderDto
{
// Unchanged. Old clients keep reading this exactly as before.
public decimal Total { get; init; }
// NEW additive fields. Old clients ignore them, new clients use them.
public decimal Subtotal { get; init; }
public decimal Tax { get; init; }
}You added subtotal and tax beside the untouched total. Old clients see no difference. New clients get richer data. No version. No new menu. No panic. That is the whole philosophy in one screen of code.
You only reach for /v2 on the rare day when the shape must truly change and no amount of adding fields can hide it. On that day, you version with care: pick one style, deprecate gently, announce a sunset date, watch your logs, and remove the old version only when it is truly empty.
Quick recap
- Adding things to an API is usually safe. Removing, renaming, or changing things breaks the contract.
- Your clients trust the shape of your responses long after they were written. Keep that promise.
- Versioning is powerful but heavy: it means maintaining old code, doubling tests, and confusing clients for years.
- Before versioning, try expand, migrate, contract: add the new thing, move clients, then remove the old thing after a deadline.
- Most improvements can ship as additive changes with no new version at all.
- When you truly must break the contract, version cleanly with the
Asp.Versioning.*packages, pick one style (URL is clearest for public APIs), and never mix styles. - Deprecate gently: set
Deprecated = true, turn onReportApiVersions, announce aSunsetdate, and watch usage before deleting. - API versioning should be your last resort, not your first instinct.
References and further reading
- dotnet/aspnet-api-versioning (official project on GitHub)
- Deprecating a Service Version (project wiki)
- Version Policies (project wiki)
- API Versioning in ASP.NET Core (Milan Jovanovic)
- API Versioning in ASP.NET Core (Code Maze)
- API Backwards Compatibility Best Practices (Zuplo)
- Versioning Best Practices in REST API Design (Speakeasy)
Related Posts
Scheduling Background Jobs with Quartz.NET in ASP.NET Core
Learn Quartz.NET step by step in ASP.NET Core: jobs, triggers, cron schedules, dependency injection, and database persistence, explained for beginners.
Real-Time Server-Sent Events in ASP.NET Core and .NET 10
Learn Server-Sent Events (SSE) in ASP.NET Core and .NET 10 with the new TypedResults.ServerSentEvents API, explained simply for beginners.
TickerQ: The Modern .NET Job Scheduler That Beats Quartz and Hangfire
Learn TickerQ, the fast, reflection-free .NET job scheduler with cron and time jobs, EF Core storage, retries, and a live dashboard, explained for beginners.
Adding Real-Time Functionality to .NET Apps with SignalR
Learn ASP.NET Core SignalR step by step: hubs, clients, groups, and scaling with Redis or Azure, explained for absolute beginners.
Improving ASP.NET Core Dependency Injection with Scrutor
Learn how Scrutor makes ASP.NET Core dependency injection easier with assembly scanning and decoration, explained in simple, beginner-friendly steps.
Scaling SignalR With a Redis Backplane in ASP.NET Core
Learn how a Redis backplane lets your ASP.NET Core SignalR app run on many servers so every connected user still gets every real-time message.