← Back to Articles         
API Versioning in ASP.NET Core

API Versioning in ASP.NET Core

Published on 15 Jun 2026     12 min read Web API
Web API API Versioning

API versioning allows you to change an API while keeping existing applications working with the older API contract.

For example:

GET /api/v1/Products/1
GET /api/v2/Products/1

Both requests retrieve a product, but each version can return a different response structure.

Below, we will use ASP.NET Core Web API with controllers and cover:

  1. Why API versioning is needed.

  2. How clients specify the version.

  3. Controller-level versioning with the same ProductsController class name.

  4. Action-level versioning inside one controller.

  5. Configuration, request flow, common mistakes, and key points.

These examples use Asp.Versioning.Mvc, with controller support registered through .AddMvc(). This follows the project’s ASP.NET Core setup. (ASP.NET API Versioning Documentation)


1. What is API versioning?

An API has a contract: the agreement between the API and its consumers.

That contract includes:

  • Request URLs and HTTP methods.

  • Request parameters and body.

  • Response property names and data types.

  • Status codes.

  • Expected behavior.

Suppose your existing API returns:

{
  "id": 1,
  "name": "Laptop",
  "price": 55000
}

Your Angular application reads:

product.name
product.price

Later, you change the response to:

{
  "id": 1,
  "productName": "Laptop",
  "pricing": {
    "amount": 55000,
    "currency": "INR"
  }
}

The old Angular application still expects name and price. Those properties no longer exist.

Changing the existing response directly can break that application.

With API versioning:

Client Endpoint Response contract
Existing application /api/v1/Products/1 name, price
Updated application /api/v2/Products/1 productName, pricing

Both applications continue working while consumers migrate to V2.

The API version is independent of the .NET version. API V1 and API V2 can run inside the same ASP.NET Core application.


2. When should you introduce a new version?

A new version is commonly needed for a breaking change.

Change Usually breaking?
Remove or rename a response property Yes
Change a property from number to string Yes
Make an optional request field mandatory Yes
Change the response from an array to a wrapper object Yes
Change behavior that clients depend on Yes
Improve SQL query performance without changing behavior No
Refactor internal code No
Add an optional response property Usually no; depends on client compatibility

You do not need a new API version for every code change.


3. Two separate versioning decisions

It helps to distinguish these two questions:

Decision Options
How does the client send the version? URL, query string, header, media type
How do you organize version implementations? Separate controllers or separate actions

For example, URL versioning can be used with either controller-level or action-level implementation.

The versioning library supports all four request mechanisms. (GitHub)

Mechanism Example
URL segment /api/v1/Products/1
Query string /api/Products/1?api-version=1.0
Custom header X-Api-Version: 1.0
Media type Accept: application/json;v=1.0

We will use URL segment versioning for the complete examples because the requested version is easy to see.


4. Create and configure the project

Create a controller-based Web API project:

dotnet new webapi --use-controllers -n ApiVersioningDemo
cd ApiVersioningDemo
dotnet add package Asp.Versioning.Mvc

Use a package release compatible with your project's target .NET framework.

Replace Program.cs with:

using Asp.Versioning;

var builder = WebApplication.CreateBuilder(args);

// Register ASP.NET Core controllers.
builder.Services.AddControllers();

// Register API versioning and its controller integration.
builder.Services
    .AddApiVersioning(options =>
    {
        options.DefaultApiVersion = new ApiVersion(1, 0);

        // Clients must explicitly specify a version.
        options.AssumeDefaultVersionWhenUnspecified = false;

        // Include supported/deprecated version information in headers.
        options.ReportApiVersions = true;

        // Read the version from URLs such as /api/v1/Products.
        options.ApiVersionReader = new UrlSegmentApiVersionReader();
    })
    .AddMvc();

var app = builder.Build();

app.MapControllers();

app.Run();

What each setting does

Code Purpose
AddControllers() Registers ASP.NET Core controller services
AddApiVersioning(...) Registers API versioning services
.AddMvc() Integrates API versioning with controllers
DefaultApiVersion Defines the default version used where applicable
AssumeDefaultVersionWhenUnspecified = false Requires an explicit version
ReportApiVersions = true Enables version reporting in response headers
UrlSegmentApiVersionReader Reads the version from the URL route
MapControllers() Maps attribute-routed controller endpoints

Important: A default version does not fill in a missing URL segment. With our route, /api/Products/1 does not automatically become /api/v1/Products/1. (GitHub)


5. Controller-level versioning — same controller name

In this approach, each version has its own controller class.

As you requested, both classes are named ProductsController.

They have different namespaces:

ApiVersioningDemo.Controllers.V1
ApiVersioningDemo.Controllers.V2

C# permits the same class name in different namespaces because the fully qualified type names are different.

Create these two files:

File Namespace Class name
Controllers/V1/ProductsController.cs ApiVersioningDemo.Controllers.V1 ProductsController
Controllers/V2/ProductsController.cs ApiVersioningDemo.Controllers.V2 ProductsController

Folders organize the files. The namespace declarations distinguish the C# classes.

V1 controller

File: Controllers/V1/ProductsController.cs

using Asp.Versioning;
using Microsoft.AspNetCore.Mvc;

namespace ApiVersioningDemo.Controllers.V1;

[ApiController]
[ApiVersion("1.0")]
[Route("api/v{version:apiVersion}/[controller]")]
public class ProductsController : ControllerBase
{
    [HttpGet("{id:int}")]
    public IActionResult GetById(int id)
    {
        // Hard-coded data keeps the versioning example simple.
        if (id != 1)
        {
            return NotFound();
        }

        return Ok(new
        {
            Id = 1,
            Name = "Laptop",
            Price = 55000m
        });
    }
}

Request:

GET http://localhost:5000/api/v1/Products/1

Response — 200 OK:

{
  "id": 1,
  "name": "Laptop",
  "price": 55000
}

V2 controller — same class name

File: Controllers/V2/ProductsController.cs

using Asp.Versioning;
using Microsoft.AspNetCore.Mvc;

namespace ApiVersioningDemo.Controllers.V2;

[ApiController]
[ApiVersion("2.0")]
[Route("api/v{version:apiVersion}/[controller]")]
public class ProductsController : ControllerBase
{
    [HttpGet("{id:int}")]
    public IActionResult GetById(int id)
    {
        if (id != 1)
        {
            return NotFound();
        }

        return Ok(new
        {
            Id = 1,
            ProductName = "Laptop",
            Pricing = new
            {
                Amount = 55000m,
                Currency = "INR"
            }
        });
    }
}

Request:

GET http://localhost:5000/api/v2/Products/1

Response — 200 OK:

{
  "id": 1,
  "productName": "Laptop",
  "pricing": {
    "amount": 55000,
    "currency": "INR"
  }
}

How does ASP.NET Core select the correct controller?

Both controllers use this route:

[Route("api/v{version:apiVersion}/[controller]")]

Its parts mean:

Part Meaning
api Literal URL prefix
v Literal character before the version
{version:apiVersion} Version route parameter with the API version constraint
[controller] Resolves to Products
{id:int} on the action Matches an integer product ID

The versioning integration uses the requested version to distinguish matching endpoints. Identical route templates can therefore serve different versions. (GitHub)

Request Selected controller type Action
/api/v1/Products/1 Controllers.V1.ProductsController GetById(1)
/api/v2/Products/1 Controllers.V2.ProductsController GetById(1)

The namespace does not select the API version in this example.

These attributes select it:

[ApiVersion("1.0")]
[ApiVersion("2.0")]

There is a separate namespace convention available, but we have not enabled it.

Why no [MapToApiVersion] here?

Each controller declares only one version, so its actions belong to that controller’s version. An additional action mapping is unnecessary.

When this approach is useful

Use separate controllers when versions have substantial differences in request models, response models, validation, or available actions.

Keep shared business logic in services so that separate controllers do not require duplicated database and business code.


6. Action-level versioning — one controller, different actions

In this approach:

  • One ProductsController supports V1 and V2.

  • [ApiVersion] declares the supported controller versions.

  • [MapToApiVersion] maps individual actions to those versions.

This is useful when only a small number of actions differ between versions. The project documentation calls this version interleaving. (ASP.NET API Versioning Documentation)

Treat this as an alternative to the previous example. Remove the two earlier ProductsController classes before adding this one, or use a separate project. Keeping both implementations would create overlapping endpoints.

Keep the same Program.cs.

File: Controllers/ProductsController.cs

using Asp.Versioning;
using Microsoft.AspNetCore.Mvc;

namespace ApiVersioningDemo.Controllers;

[ApiController]
[ApiVersion("1.0")]
[ApiVersion("2.0")]
[Route("api/v{version:apiVersion}/[controller]")]
public class ProductsController : ControllerBase
{
    // GET /api/v1/Products/1
    [HttpGet("{id:int}")]
    [MapToApiVersion("1.0")]
    public IActionResult GetByIdV1(int id)
    {
        if (id != 1)
        {
            return NotFound();
        }

        return Ok(new
        {
            Id = 1,
            Name = "Laptop",
            Price = 55000m
        });
    }

    // GET /api/v2/Products/1
    [HttpGet("{id:int}")]
    [MapToApiVersion("2.0")]
    public IActionResult GetByIdV2(int id)
    {
        if (id != 1)
        {
            return NotFound();
        }

        return Ok(new
        {
            Id = 1,
            ProductName = "Laptop",
            Pricing = new
            {
                Amount = 55000m,
                Currency = "INR"
            }
        });
    }

    // This action is explicitly available in both versions.
    [HttpGet("categories")]
    [MapToApiVersion("1.0")]
    [MapToApiVersion("2.0")]
    public IActionResult GetCategories()
    {
        return Ok(new[] { "Electronics", "Books" });
    }
}

Action selection

Request Selected action
/api/v1/Products/1 GetByIdV1(1)
/api/v2/Products/1 GetByIdV2(1)
/api/v1/Products/categories GetCategories()
/api/v2/Products/categories GetCategories()

The product responses are the same as in the controller-level example.

Both category requests return:

[
  "Electronics",
  "Books"
]

Why do both product actions have the same [HttpGet] route?

[HttpGet("{id:int}")]

They represent the same operation for different API versions.

The mappings distinguish them:

[MapToApiVersion("1.0")]
[MapToApiVersion("2.0")]

Do method names control the URL?

No. In these examples, attribute routes control the URL.

The client calls:

GET /api/v2/Products/1

It does not call:

GET /api/v2/Products/GetByIdV2/1

Naming the method GetByIdV2 helps developers read the code; the name does not establish its API version.

Does [MapToApiVersion] replace [ApiVersion]?

No.

[ApiVersion("1.0")]
[ApiVersion("2.0")]

declares the controller’s versions.

[MapToApiVersion("2.0")]

maps an action to one of those versions.

Declare the version at controller level and then map the appropriate actions.


7. Run and test either example

Start the application with a fixed local HTTP address:

dotnet run --no-launch-profile --urls http://localhost:5000

Test V1:

curl -i http://localhost:5000/api/v1/Products/1

Test V2:

curl -i http://localhost:5000/api/v2/Products/1

You can also use a browser or Postman because these are GET requests.

Test a missing product:

GET http://localhost:5000/api/v1/Products/999

Result:

404 Not Found

That 404 comes from our action:

return NotFound();

A request for an unsupported version does not automatically fall back to V1 or V2.


8. Controller-level versus action-level

Point Separate versioned controllers Versioned actions in one controller
Number of controller classes One per version in our example One supporting multiple versions
Controller name Same name, different namespaces One class name
Version declaration [ApiVersion] on each controller Multiple [ApiVersion] attributes
Action selection Inherits the controller’s single version [MapToApiVersion]
Useful when Versions differ substantially Changes are limited to a few actions
Main maintenance concern Duplicating shared logic Controller becomes crowded
External URLs /api/v1/Products/1, /api/v2/Products/1 Same URLs

Clients do not need to know which internal organization you use.


9. Using query strings or headers instead

You can retain either controller organization and change how clients supply the version.

For these alternatives, replace the controller route with:

[Route("api/[controller]")]

This removes the required version segment.

Query-string versioning

Replace the reader in Program.cs:

options.ApiVersionReader =
    new QueryStringApiVersionReader("api-version");

Requests:

GET /api/Products/1?api-version=1.0
GET /api/Products/1?api-version=2.0

Header versioning

Replace the reader with:

options.ApiVersionReader =
    new HeaderApiVersionReader("X-Api-Version");

Request:

GET /api/Products/1
X-Api-Version: 2.0

The [ApiVersion] and [MapToApiVersion] attributes remain unchanged.

Supporting old clients that omit a version

For a query-string or header design with a matching unversioned URL, you can configure:

options.DefaultApiVersion = new ApiVersion(1, 0);
options.AssumeDefaultVersionWhenUnspecified = true;

With the default selector, a request that omits the version can then select V1. This option is particularly relevant when adding versioning to an existing API whose clients never supplied a version. (dotnet/aspnet-api-versioning Wiki)

It does not mean “use V1 when someone explicitly requests an unsupported version.”


10. Deprecating an older version

Deprecation means:

“This version still works, but consumers should migrate away from it.”

Mark V1 as deprecated:

[ApiVersion("1.0", Deprecated = true)]

For the action-level controller:

[ApiVersion("1.0", Deprecated = true)]
[ApiVersion("2.0")]

With version reporting enabled, clients can receive supported and deprecated version information in response headers.

Deprecating a version does not remove its implementation or automatically stop requests.

A practical migration process is:

  1. Release V2.

  2. Document the contract differences.

  3. Announce V1’s retirement date.

  4. Monitor remaining V1 usage.

  5. Help consumers migrate.

  6. Remove V1 when the support period ends.


11. Common mistakes

Mistake Correct approach
Same controller class name in the same namespace Use different namespaces
Create V1/V2 folders but leave identical namespace declarations Update the namespace in each file
Assume a V2 namespace automatically enables V2 Use [ApiVersion], or explicitly configure namespace conventions
Register AddApiVersioning() but omit .AddMvc() Add controller integration
Name an action GetV2 without version metadata Use [MapToApiVersion("2.0")]
Map an action to V2 without declaring V2 on the controller Add [ApiVersion("2.0")]
Expect a default version to repair a missing URL segment Supply the segment or use an appropriate unversioned route
Keep both complete sample approaches active Choose one implementation for these endpoints
Return database entities directly and change them freely Use explicit response DTOs to protect API contracts

For a production application, replace the anonymous response objects with version-specific DTOs such as ProductV1Response and ProductV2Response. Both versions can still use the same database and shared services.

Key points

  • API versioning protects existing clients when API contracts change.

  • API V1/V2 are unrelated to your .NET runtime version.

  • Use Asp.Versioning.Mvc for the controller approach shown here.

  • Register AddControllers() and AddApiVersioning(...).AddMvc().

  • Controller-level versioning can use the same controller name in different namespaces.

  • [ApiVersion] declares supported versions.

  • [MapToApiVersion] maps individual actions to declared versions.

  • URL, query-string, header, and media-type versioning describe how the client supplies the version.

  • Default-version selection does not provide fallback for explicitly unsupported versions.

  • Deprecation signals migration; it does not disable the endpoint.

  • Preserve each supported version’s contract and test it independently.

 

API versioning does not require the method name GetByIdV2 or GetById2. The name depends on how you organize your controllers.

1. Separate controllers — same method name is allowed

Because the methods are in different classes, both can be named GetById.

// Controllers/V1/ProductsController.cs
namespace ApiVersioningDemo.Controllers.V1;

[ApiVersion("1.0")]
public class ProductsController : ControllerBase
{
    [HttpGet("{id:int}")]
    public IActionResult GetById(int id)
    {
        return Ok("Version 1");
    }
}
// Controllers/V2/ProductsController.cs
namespace ApiVersioningDemo.Controllers.V2;

[ApiVersion("2.0")]
public class ProductsController : ControllerBase
{
    [HttpGet("{id:int}")]
    public IActionResult GetById(int id)
    {
        return Ok("Version 2");
    }
}

Controller route attributes are omitted above for brevity.

2. Same controller — different names are needed when parameters are identical

C# does not allow two methods with the same name and parameter types in the same class.

Therefore, this is valid:

[HttpGet("{id:int}")]
[MapToApiVersion("1.0")]
public IActionResult GetById(int id)
{
    return Ok("Version 1");
}

[HttpGet("{id:int}")]
[MapToApiVersion("2.0")]
public IActionResult GetByIdV2(int id)
{
    return Ok("Version 2");
}

But naming both methods GetById(int id) would cause a C# compilation error. Different attributes do not make their C# signatures different.

Implementation Can both methods be GetById(int id)?
Separate controller classes ✅ Yes
Same controller class ❌ No

[MapToApiVersion("2.0")] selects the version. The method name does not. With the attribute routes above, clients still call /api/v2/Products/1.

← Back to Articles