DEV Community

Cristian Sifuentes
Cristian Sifuentes

Posted on

ASP.NET Core Route Names & API Versioning — From “Duplicate Name” Crash to Intentional Routing

ASP.NET Core Route Names & API Versioning — From “Duplicate Name” Crash to Intentional Routing

ASP.NET Core Route Names & API Versioning — From “Duplicate Name” Crash to Intentional Routing

Most .NET developers first meet ASP.NET Core attribute routing in a happy path like this:

[ApiController]
[Route("api/[controller]")]
public class CategoriesController : ControllerBase
{
    [HttpGet("{id:int}", Name = "GetCategory")]
    public IActionResult GetCategory(int id) { ... }
}
Enter fullscreen mode Exit fullscreen mode

Hit F5, the app runs, CreatedAtRoute("GetCategory", ...) works, and everything feels good.

Then one day you add API versioning and suddenly your app dies on startup with something like:

Attribute routes with the same name 'GetCategory' must have the same template

This post will walk you through:

  • What this error really means.
  • Why it appears as soon as you start versioning controllers.
  • Three clean ways to fix it (including when each one makes sense architecturally).
  • How to design versioned routes and route names on purpose, not by accident.

We’ll use an example based on your real error involving:

ApiEcommerce.Controllers.CategoriesController
ApiEcommerce.Controllers.V1.CategoriesController
ApiEcommerce.Controllers.V2.CategoriesController
Enter fullscreen mode Exit fullscreen mode

Table of Contents

  1. The Error in Plain English
  2. How Attribute Route Names Actually Work
  3. Where Versioning Made Things Blow Up
  4. Strategy 1 — Unique Route Names per Version
  5. Strategy 2 — Remove Names from Legacy Controllers
  6. Strategy 3 — Align Templates When Sharing a Name
  7. How to Audit and Fix Your Project Step‑by‑Step
  8. Versioning Design Tips for Real APIs
  9. Checklist for Your Next Versioned Controller

1. The Error in Plain English

The runtime exception is ASP.NET Core telling you:

“You gave the same route name (GetCategory, UpdateCategory, DeleteCategory) to different URL templates. I don’t know which one is which, so I’m stopping.”

In your case, you have three controllers that all define actions like this:

// Non‑versioned controller
[HttpGet("api/Categories/{id:int}", Name = "GetCategory")]
[HttpPut("api/Categories/{id:int}", Name = "UpdateCategory")]
[HttpDelete("api/Categories/{id:int}", Name = "DeleteCategory")]
Enter fullscreen mode Exit fullscreen mode

and versioned ones like:

// Versioned controllers
[HttpGet("api/v{version:apiVersion}/Categories/{id:int}", Name = "GetCategory")]
[HttpPut("api/v{version:apiVersion}/Categories/{id:int}", Name = "UpdateCategory")]
[HttpDelete("api/v{version:apiVersion}/Categories/{id:int}", Name = "DeleteCategory")]
Enter fullscreen mode Exit fullscreen mode

So you end up with same Name + different Template. ASP.NET Core does not allow that.

Why? Because route names are used as unique keys for URL generation (Url.Link, CreatedAtRoute, RedirectToRoute, etc.). If two different routes share the same name but use different templates, ASP.NET Core literally doesn’t know which one to resolve when you call Url.Link("GetCategory", ...).


2. How Attribute Route Names Actually Work

There are three distinct concepts you must separate in your mind:

  1. Route template — the URL pattern, e.g.
   api/Categories/{id:int}
   api/v{version:apiVersion}/Categories/{id:int}
Enter fullscreen mode Exit fullscreen mode
  1. Route name — a global logical identifier for that route, e.g.
   Name = "GetCategory"
Enter fullscreen mode Exit fullscreen mode
  1. Action name / method name — your C# method name; ASP.NET Core does not care about this for uniqueness.

Key rules:

  • Route names must be unique per route template. ASP.NET Core requires that all routes sharing the same name also share the same template.
  • If templates differ even slightly (api/Categories/{id:int} vs api/v{version:apiVersion}/Categories/{id:int}), they are considered distinct routes.
  • Route names matter when you call things like:
  return CreatedAtRoute("GetCategory", new { id = category.Id }, category);
Enter fullscreen mode Exit fullscreen mode

ASP.NET Core will search for a route named GetCategory and generate a URL based on its template.

As long as you had just one CategoriesController, everything was fine. Once you duplicated the actions into V1 and V2 controllers with different templates but the same names, the constraint was violated.


3. Where Versioning Made Things Blow Up

Now consider your three controllers:

ApiEcommerce.Controllers.CategoriesController
ApiEcommerce.Controllers.V1.CategoriesController
ApiEcommerce.Controllers.V2.CategoriesController
Enter fullscreen mode Exit fullscreen mode
  • The non‑versioned one uses api/Categories/{id:int}.
  • The versioned ones use api/v{version:apiVersion}/Categories/{id:int}.

But all three share route names: "GetCategory", "UpdateCategory", "DeleteCategory".

When ASP.NET Core boots, it scans all controllers, flattens route info, and finds:

  • Route name GetCategorythree different templates.
  • Same for UpdateCategory and DeleteCategory.

=> 💥 Boom: “Attribute routes with the same name 'GetCategory' must have the same template”.

So the good news: nothing is "wrong" with ASP.NET Core. It’s protecting you from ambiguous route generation.

Now let’s turn that into a clean design.


4. Strategy 1 — Unique Route Names per Version

This is the most explicit and often the cleanest approach when you want to keep multiple versions alive.

Non‑versioned controller

[ApiController]
[Route("api/[controller]")]
public class CategoriesController : ControllerBase
{
    [HttpGet("{id:int}", Name = "GetCategory")]
    public IActionResult GetCategory(int id) { ... }

    [HttpPut("{id:int}", Name = "UpdateCategory")]
    public IActionResult UpdateCategory(int id, CategoryDto dto) { ... }

    [HttpDelete("{id:int}", Name = "DeleteCategory")]
    public IActionResult DeleteCategory(int id) { ... }
}
Enter fullscreen mode Exit fullscreen mode

V1 controller

namespace ApiEcommerce.Controllers.V1;

[ApiController]
[ApiVersion("1.0")]
[Route("api/v{version:apiVersion}/[controller]")]
public class CategoriesController : ControllerBase
{
    [HttpGet("{id:int}", Name = "GetCategoryV1")]
    public IActionResult GetCategory(int id) { ... }

    [HttpPut("{id:int}", Name = "UpdateCategoryV1")]
    public IActionResult UpdateCategory(int id, CategoryDto dto) { ... }

    [HttpDelete("{id:int}", Name = "DeleteCategoryV1")]
    public IActionResult DeleteCategory(int id) { ... }
}
Enter fullscreen mode Exit fullscreen mode

V2 controller

namespace ApiEcommerce.Controllers.V2;

[ApiController]
[ApiVersion("2.0")]
[Route("api/v{version:apiVersion}/[controller]")]
public class CategoriesController : ControllerBase
{
    [HttpGet("{id:int}", Name = "GetCategoryV2")]
    public IActionResult GetCategory(int id) { ... }

    [HttpPut("{id:int}", Name = "UpdateCategoryV2")]
    public IActionResult UpdateCategory(int id, CategoryDto dto) { ... }

    [HttpDelete("{id:int}", Name = "DeleteCategoryV2")]
    public IActionResult DeleteCategory(int id) { ... }
}
Enter fullscreen mode Exit fullscreen mode

Why this works: each version has its own route names (GetCategoryV1, GetCategoryV2, etc.), so there’s no conflict.

When to use it:

  • You actively support multiple versions at the same time.
  • You want to be explicit about which version your CreatedAtRoute calls refer to:
  return CreatedAtRoute("GetCategoryV2", new { id = category.Id, version = "2.0" }, category);
Enter fullscreen mode Exit fullscreen mode

5. Strategy 2 — Remove Route Names from Legacy / Non‑Versioned Controllers

If the non‑versioned CategoriesController is basically legacy and your real contract is now /api/v1/... and /api/v2/..., you can simplify:

In ApiEcommerce.Controllers.CategoriesController:

// BEFORE
[HttpGet("api/Categories/{id:int}", Name = "GetCategory")]
[HttpPut("api/Categories/{id:int}", Name = "UpdateCategory")]
[HttpDelete("api/Categories/{id:int}", Name = "DeleteCategory")]

// AFTER
[HttpGet("api/Categories/{id:int}")]
[HttpPut("api/Categories/{id:int}")]
[HttpDelete("api/Categories/{id:int}")]
Enter fullscreen mode Exit fullscreen mode
  • No route names → no conflicts.
  • Any old code that relied on CreatedAtRoute("GetCategory", ...) for this controller would break, but often legacy controllers aren’t used for hypermedia-style responses anyway.

Even better: if you truly don’t need that controller anymore… delete it (or at least comment it out). Versioned controllers should be your source of truth going forward.

When to use it:

  • The non‑versioned controller is only there for backward compatibility or temporary testing.
  • All real clients call /api/v1/... and /api/v2/... endpoints.

6. Strategy 3 — Align Templates When Sharing a Name

ASP.NET Core does allow multiple actions to share the same route name if the template is identical.

Right now you have:

api/Categories/{id:int}
api/v{version:apiVersion}/Categories/{id:int}
Enter fullscreen mode Exit fullscreen mode

Those are not the same.

If you really wanted the non‑versioned controller to behave like “v1 without the namespace noise”, you could:

  • Move it under the V1 namespace, or
  • Give it the same route prefix so the template is exactly the same:
namespace ApiEcommerce.Controllers.V1;

[ApiController]
[ApiVersion("1.0")]
[Route("api/v{version:apiVersion}/[controller]")]
public class CategoriesController : ControllerBase
{
    [HttpGet("{id:int}", Name = "GetCategory")]
    public IActionResult GetCategory(int id) { ... }
}
Enter fullscreen mode Exit fullscreen mode

Now your templates match:

api/v{version:apiVersion}/Categories/{id:int}
Enter fullscreen mode Exit fullscreen mode

and sharing the same route name is allowed.

When to use it:

  • You really want one logical route “slot” per version and multiple actions/methods pointing to it.
  • You’re very comfortable reasoning about versioning at the route prefix level.

For most teams, Strategy 1 (unique names per version) is simpler and less surprising.


7. How to Audit and Fix Your Project Step‑by‑Step

Here’s how you can methodically clean things up in a real repo like your ApiEcommerce solution.

7.1 Open all involved controllers

Look at:

  • Controllers/CategoriesController.cs
  • Controllers/V1/CategoriesController.cs
  • Controllers/V2/CategoriesController.cs

Search for route attributes:

[HttpGet("{id:int}", Name = "GetCategory")]
[HttpPut("{id:int}", Name = "UpdateCategory")]
[HttpDelete("{id:int}", Name = "DeleteCategory")]
Enter fullscreen mode Exit fullscreen mode

7.2 Decide your strategy

Ask yourself:

  • Do I really need the non‑versioned controller? Or is it legacy?
  • Which versions are officially supported?
  • How do clients currently discover URLs (hardcoded, OpenAPI, CreatedAtRoute, etc.)?

Then pick one strategy:

  • Strategy 1 — rename per version: GetCategoryV1, GetCategoryV2, etc.
  • Strategy 2 — remove names from legacy/non‑versioned controller.
  • Strategy 3 — align templates if you intentionally share a name.

7.3 Update attributes

Apply your chosen pattern consistently to:

  • GetCategory
  • UpdateCategory
  • DeleteCategory
  • Any other versioned actions that share route names.

7.4 Rebuild & run

dotnet build
dotnet run
Enter fullscreen mode Exit fullscreen mode

If the app starts without the “same route name” error, routing metadata is now consistent.


8. Versioning Design Tips for Real APIs

Route naming and versioning problems are often a symptom of a deeper issue: unclear versioning strategy.

Here are some practical design tips for production APIs:

8.1 Pick one primary versioning style per API

Common options:

  1. URL segment (what you are using):
   /api/v1/Categories/{id}
   /api/v2/Categories/{id}
Enter fullscreen mode Exit fullscreen mode
  1. Query string:
   /api/Categories/{id}?api-version=1.0
Enter fullscreen mode Exit fullscreen mode
  1. Header‑based:
   GET /api/Categories/10
   api-version: 2.0
Enter fullscreen mode Exit fullscreen mode

Mixing them randomly multiplies complexity. Choose one as the “public contract” and stick to it.

8.2 Use namespaces to reflect versions

You’re already doing this (good!):

ApiEcommerce.Controllers.V1
ApiEcommerce.Controllers.V2
Enter fullscreen mode Exit fullscreen mode

Pair that with clear route prefixes:

[Route("api/v{version:apiVersion}/[controller]")]
Enter fullscreen mode Exit fullscreen mode

This keeps the controller code and URL surface aligned.

8.3 Keep route names meaningful and predictable

Instead of generic names like "GetCategory" in every version, think of route names as part of your API surface:

  • GetCategoryV1
  • GetCategoryV2
  • UpdateCategoryV2
  • DeleteCategoryV2

Clients that use CreatedAtRoute or Url.Link can rely on stable names that encode version intent.

8.4 Don’t be afraid to delete old controllers

Dead code is a huge source of routing confusion.

If you truly no longer support non‑versioned /api/Categories/...:

  • Remove CategoriesController from the root Controllers folder.
  • Keep only V1 and V2 under their namespaces.
  • Update any tests or tools that were still calling old routes.

9. Checklist for Your Next Versioned Controller

When you add a new version (say, V3), run through this checklist:

Routing & Versioning

  • [ ] Controller lives under a versioned namespace (e.g., Controllers.V3).
  • [ ] Route prefix is consistent: api/v{version:apiVersion}/[controller].
  • [ ] [ApiVersion("3.0")] (or similar) is applied.

Route Names

  • [ ] Route names are unique per version: GetCategoryV3, not just GetCategory.
  • [ ] Any CreatedAtRoute calls reference the correct versioned route name.
  • [ ] No two different templates share the same route name.

Cleanup

  • [ ] Legacy/non‑versioned controllers don’t reuse route names, or are removed.
  • [ ] Swagger/OpenAPI docs group endpoints by version (via ApiExplorer or Swashbuckle config).

If you follow this discipline, you’ll almost never see the “Attribute routes with the same name … must have the same template” error again — and if you do, you’ll know exactly where to look.


Final Thoughts

This routing error is not just a random annoyance; it’s ASP.NET Core nudging you toward explicit, unambiguous API design.

Once you:

  • Understand that route names are global identifiers, not mere labels,
  • Treat API versions as first‑class citizens of your URL design, and
  • Apply a clear naming convention per version,

your APIs become easier to reason about, easier to evolve, and much friendlier to clients that rely on hypermedia and URL generation.

Happy coding — and may your route tables be always clean, intentional, and free of duplicate names. 🚀

Top comments (0)