Skip to main content
SEMastery
ASP.NETintermediate

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.

13 min readUpdated April 11, 2026

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.

A client trusts the shape of your responses long after it was written.

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 makeSafe or breaking?Why
Add a new field to a responseSafeOld clients just ignore unknown fields
Add a new optional query parameterSafeOld clients do not send it, default is used
Add a brand-new endpointSafeOld clients never call it
Make a required field optionalUsually safeOld clients still send it, that is fine
Remove a field from a responseBreakingA client reading that field gets null or crashes
Rename a fieldBreakingSame as removing the old name
Change a field's type (string to number)BreakingParsing fails on the client
Make an optional field requiredBreakingOld requests are now rejected
Change the meaning of a valueBreakingClient 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

Want a change
Is it additive?
Ship additively
Must break contract
Version

Steps

1

Want a change

You have a new need

2

Is it additive?

Add, do not remove or change

3

Ship additively

No version needed

4

Must break contract

Removing or changing shape

5

Version

Last resort, do it cleanly

Walk this path every time you want to change an API.

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.

Each new version multiplies the surface you must maintain and test forever.

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.

  1. Expand. Add the new thing next to the old thing. Both exist together.
  2. Migrate. Tell clients to move to the new thing. Watch your logs to see who still uses the old one.
  3. Contract. Only after everyone has moved, and the deadline passes, remove the old thing.

Expand, migrate, contract

Expand
Migrate
Contract

Steps

1

Expand

Add new field beside old

2

Migrate

Move clients, watch logs

3

Contract

Remove old after deadline

Change shape without breaking anyone, by overlapping old and new.

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.

StyleWhat it looks likeBest for
URL path/api/v2/ordersPublic APIs, easy to read and test
Query string/api/orders?api-version=2.0Quick demos, simple internal tools
HeaderX-Api-Version: 2.0Internal service-to-service calls
Media typeAccept: application/json;v=2.0Strict 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 versioning middleware reads the requested version and routes to the right 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

Mark deprecated
Announce sunset date
Monitor usage
Remove after date

Steps

1

Mark deprecated

Set Deprecated = true

2

Announce sunset date

Send Sunset header + docs

3

Monitor usage

Watch who still calls v1

4

Remove after date

Delete only when traffic is gone

Give clients a real deadline, not a surprise.

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 on ReportApiVersions, announce a Sunset date, and watch usage before deleting.
  • API versioning should be your last resort, not your first instinct.

References and further reading

Related Posts