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) { ... }
}
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
Table of Contents
- The Error in Plain English
- How Attribute Route Names Actually Work
- Where Versioning Made Things Blow Up
- Strategy 1 — Unique Route Names per Version
- Strategy 2 — Remove Names from Legacy Controllers
- Strategy 3 — Align Templates When Sharing a Name
- How to Audit and Fix Your Project Step‑by‑Step
- Versioning Design Tips for Real APIs
- 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")]
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")]
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:
- Route template — the URL pattern, e.g.
api/Categories/{id:int}
api/v{version:apiVersion}/Categories/{id:int}
- Route name — a global logical identifier for that route, e.g.
Name = "GetCategory"
- 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}vsapi/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);
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
- 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
GetCategory→ three different templates. - Same for
UpdateCategoryandDeleteCategory.
=> 💥 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) { ... }
}
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) { ... }
}
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) { ... }
}
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
CreatedAtRoutecalls refer to:
return CreatedAtRoute("GetCategoryV2", new { id = category.Id, version = "2.0" }, category);
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}")]
- 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}
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
V1namespace, 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) { ... }
}
Now your templates match:
api/v{version:apiVersion}/Categories/{id:int}
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.csControllers/V1/CategoriesController.csControllers/V2/CategoriesController.cs
Search for route attributes:
[HttpGet("{id:int}", Name = "GetCategory")]
[HttpPut("{id:int}", Name = "UpdateCategory")]
[HttpDelete("{id:int}", Name = "DeleteCategory")]
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:
GetCategoryUpdateCategoryDeleteCategory- Any other versioned actions that share route names.
7.4 Rebuild & run
dotnet build
dotnet run
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:
- URL segment (what you are using):
/api/v1/Categories/{id}
/api/v2/Categories/{id}
- Query string:
/api/Categories/{id}?api-version=1.0
- Header‑based:
GET /api/Categories/10
api-version: 2.0
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
Pair that with clear route prefixes:
[Route("api/v{version:apiVersion}/[controller]")]
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:
GetCategoryV1GetCategoryV2UpdateCategoryV2DeleteCategoryV2
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
CategoriesControllerfrom the rootControllersfolder. - Keep only
V1andV2under 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 justGetCategory. - [ ] Any
CreatedAtRoutecalls 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
ApiExploreror 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)