Mert Özen Further With Every Line
Backend .NET Core Web API — #05

Routing in ASP.NET Core Web API: Attribute Routing, Route Templates and Constraints

Mert Özen Nov 4, 2026 8 min 2 views
Routing in ASP.NET Core Web API: Attribute Routing, Route Templates and Constraints

The decide-and-execute steps of endpoint routing, attribute routing and the [controller]/[action] tokens, route template parts, constraints and precedence, conflicting routes and AmbiguousMatchException.

Version note: The code in this post is written for .NET 6 and runs unchanged on .NET 10.

Guess First

Below is the ProductCatalog API in its simplest form. A client mistakenly sends text instead of an id: GET /api/products/abc. Which status code comes back? 400 Bad Request, 404 Not Found, or a 500 because abc cannot be converted to a number?

using Microsoft.AspNetCore.Mvc;
using ProductCatalog.Api.Models;

namespace ProductCatalog.Api.Controllers;

[ApiController]
[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
    private static readonly List<Product> Products = new()
    {
        new Product { Id = 1, Name = "Mechanical Keyboard", Price = 1450m, CategoryId = 1 },
        new Product { Id = 2, Name = "Wireless Mouse", Price = 520m, CategoryId = 1 },
        new Product { Id = 3, Name = "USB-C Hub", Price = 890m, CategoryId = 2 }
    };

    [HttpGet("{id:int}")]
    public ActionResult<Product> GetById(int id)
    {
        var product = Products.FirstOrDefault(p => p.Id == id);
        return product is null ? NotFound() : product;
    }
}

Endpoint Routing: Decision and Execution

The answer is 404, but the interesting part is what kind of 404 it is:

$ curl -i http://localhost:5214/api/products/abc
HTTP/1.1 404 Not Found
Content-Length: 0
Date: Tue, 03 Nov 2026 21:34:52 GMT
Server: Kestrel

$ curl -s -o /dev/null -w "%{http_code} %{content_type}\n" http://localhost:5214/api/products/abc
404
$ curl -s -o /dev/null -w "%{http_code} %{content_type}\n" http://localhost:5214/api/products/99
404 application/problem+json; charset=utf-8

Both are 404, but one is empty and the other has a ProblemDetails body. For 99 the action ran, did not find the product and returned NotFound(). For abc the action never ran at all. The {id:int} constraint rejected abc, routing found no other matching endpoint, and the request came back with a 404 without reaching any controller. As far as routing is concerned, there is no address called /api/products/abc.

In ASP.NET Core this happens in two steps. The UseRouting middleware we saw in the previous post looks at the request's path and method, picks one of all the defined endpoints and attaches it to the HttpContext. The endpoint middleware at the end of the pipeline then runs the selected endpoint. Every middleware between the two steps can see which endpoint was selected; that is how authorization knows whether an endpoint is [Authorize]. Let's see it with our own eyes:

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();

var app = builder.Build();

app.Use(async (context, next) =>
{
    var endpoint = context.GetEndpoint();
    app.Logger.LogInformation("{Path} matched {Endpoint}", context.Request.Path, endpoint?.DisplayName ?? "(none)");
    await next();
});

app.MapControllers();

app.MapGet("/api/orders/{orderId:guid}", (Guid orderId) => new { orderId, Status = "Pending" });

app.MapGet("/debug/routes", (IEnumerable<EndpointDataSource> sources) =>
    sources.SelectMany(s => s.Endpoints)
           .OfType<RouteEndpoint>()
           .Select(e => e.RoutePattern.RawText));

app.Run();

We did not call UseRouting here; because the WebApplication in .NET 6 adds it at the start of the pipeline by itself, the decision has already been made by the time our middleware runs. The /debug/routes endpoint lists every route in the application. Keep this helper in the development environment only.

A Controller with Attribute Routing

Let's extend the controller a bit:

using Microsoft.AspNetCore.Mvc;
using ProductCatalog.Api.Models;

namespace ProductCatalog.Api.Controllers;

[ApiController]
[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
    private static readonly List<Product> Products = new()
    {
        new Product { Id = 1, Name = "Mechanical Keyboard", Price = 1450m, CategoryId = 1 },
        new Product { Id = 2, Name = "Wireless Mouse", Price = 520m, CategoryId = 1 },
        new Product { Id = 3, Name = "USB-C Hub", Price = 890m, CategoryId = 2 }
    };

    [HttpGet]
    public IEnumerable<Product> GetAll() => Products;

    [HttpGet("{id:int:min(1)}")]
    public ActionResult<Product> GetById(int id)
    {
        var product = Products.FirstOrDefault(p => p.Id == id);
        return product is null ? NotFound() : product;
    }

    [HttpGet("{name}")]
    public ActionResult<Product> GetByName(string name)
    {
        var product = Products.FirstOrDefault(p => p.Name.Replace(" ", "-").Equals(name, StringComparison.OrdinalIgnoreCase));
        return product is null ? NotFound() : product;
    }

    [HttpGet("/api/categories/{categoryId:int}/products")]
    public IEnumerable<Product> GetByCategory(int categoryId) =>
        Products.Where(p => p.CategoryId == categoryId);

    [HttpGet("[action]")]
    public int Count() => Products.Count;
}

With attribute routing you write the route right above the action. [Route("api/[controller]")] on the controller defines a prefix, and [HttpGet("...")] on the action defines the rest of the route and the accepted method. The two are combined: for GetById the result is api/Products/{id:int:min(1)}. [HttpGet], [HttpPost], [HttpPut], [HttpPatch] and [HttpDelete] all work the same way.

The square brackets are tokens. [controller] becomes Products by dropping the Controller suffix from the class name; [action] turns into the method name, which is why the Count action lives at api/Products/Count. Thanks to tokens, when you rename the controller its routes update by themselves. There is a side effect. The capital P in the Location: http://localhost:5214/api/Products/4 header you saw in the first post comes from this [controller] token. Matching is case-insensitive, but the links you generate carry the casing of the class name.

The route on GetByCategory starts with /. A template that starts with / (or ~/) ignores the controller's prefix. This lets you keep addresses that live under another resource, such as the products of a category, in the relevant controller.

The Parts of a Route Template

A template is made of segments separated by /. Plain text such as api is a literal segment and must match exactly. Curly braces are parameters: {id} captures the value in that segment and binds it to the action parameter with the same name. Put a ? at the end of a parameter and it becomes optional ({slug?}); with = you give it a default value ({page=1}). A catch-all parameter such as {**path} takes the entire rest of the path as a single string; it is used for values such as file paths.

Every route our application knows about is in the /debug/routes output:

$ curl http://localhost:5214/debug/routes
["api/Products","api/Products/{id:int:min(1)}","api/Products/{name}","api/categories/{categoryId:int}/products","api/Products/Count","/api/orders/{orderId:guid}","/debug/routes"]

You can see that the tokens have been replaced with the real names and that GetByCategory has no prefix.

Constraints and Precedence

A constraint says which values a parameter accepts: int, long, bool, guid and datetime check the type; min(1), max(100) and range(1,100) a numeric range; minlength(3) and length(3,20) the length; alpha and regex(...) the format. You chain several with colons: {id:int:min(1)}. Now let's send a few requests to the same controller:

$ curl http://localhost:5214/api/products/2
{"id":2,"name":"Wireless Mouse","price":520,"categoryId":1}
$ curl http://localhost:5214/api/products/usb-c-hub
{"id":3,"name":"USB-C Hub","price":890,"categoryId":2}
$ curl http://localhost:5214/api/products/count
3
$ curl http://localhost:5214/api/categories/2/products
[{"id":3,"name":"USB-C Hub","price":890,"categoryId":2}]

In the same session I also sent one request each for abc and 0. The message lines in our middleware's logs show which action each request went to:

/api/products/2 matched ProductCatalog.Api.Controllers.ProductsController.GetById (ProductCatalog.Api)
/api/products/abc matched ProductCatalog.Api.Controllers.ProductsController.GetByName (ProductCatalog.Api)
/api/products/0 matched ProductCatalog.Api.Controllers.ProductsController.GetByName (ProductCatalog.Api)
/api/products/usb-c-hub matched ProductCatalog.Api.Controllers.ProductsController.GetByName (ProductCatalog.Api)
/api/products/count matched ProductCatalog.Api.Controllers.ProductsController.Count (ProductCatalog.Api)
/api/categories/2/products matched ProductCatalog.Api.Controllers.ProductsController.GetByCategory (ProductCatalog.Api)

Look closely. The value 2 could have matched both {id:int:min(1)} and {name}, but GetById was selected. count could have matched both the literal of the Count action and {name}; Count won. Routing has a precedence order: a literal segment comes before a constrained parameter, and a constrained parameter comes before a plain one. The more specific route wins.

0, on the other hand, failed min(1) and fell through to GetByName, and so did abc. Both now get a 404 with a ProblemDetails body returned by the action. In other words, when you add or remove a constraint, the behavior of neighboring routes can change, not just that route's.

Conflicting Routes

What happens when the precedence order cannot tell two routes apart? Let both actions have an unconstrained parameter:

using Microsoft.AspNetCore.Mvc;

namespace ProductCatalog.Api.Controllers;

[ApiController]
[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
    [HttpGet("{id}")]
    public IActionResult GetById(int id) => Ok(id);

    [HttpGet("{name}")]
    public IActionResult GetByName(string name) => Ok(name);
}

This code compiles silently for the net6.0 target. The error comes at runtime, on the first request, and the status code is 500:

Microsoft.AspNetCore.Routing.Matching.AmbiguousMatchException: The request matched multiple endpoints. Matches:

ProductCatalog.Api.Controllers.ProductsController.GetById (ProductCatalog.Api)
ProductCatalog.Api.Controllers.ProductsController.GetByName (ProductCatalog.Api)

When I built the same code for the net10.0 target, the compiler caught it ahead of time: warning ASP0023: Route '{name}' conflicts with another action route. This analyzer is not there for the .NET 6 target, so in .NET 6 projects you have to catch conflicts with tests or with a list like /debug/routes. The fix is to really separate the routes: add a constraint ({id:int}), add a literal segment (by-name/{name}), or, if one of them uses a different method, mark it with the right attribute. You can also set precedence with the Order property, but most of the time making the route clearer is the more readable fix.

The Same Language in Minimal APIs

The template and constraint language is not specific to controllers. The order endpoint in the example is a minimal API, and it uses the same {orderId:guid} syntax:

$ curl http://localhost:5214/api/orders/3f2504e0-4f89-11d3-9a0c-0305e82c3301
{"orderId":"3f2504e0-4f89-11d3-9a0c-0305e82c3301","status":"Pending"}
$ curl -s -o /dev/null -w "%{http_code}\n" http://localhost:5214/api/orders/42
404

Since 42 is not a Guid, no endpoint matched; our middleware's log for this request said /api/orders/42 matched (none). We will cover minimal APIs in detail in their own post.

Common Pitfalls

Treating constraints as validation. {id:int} is not a validation rule, it is a matching rule. For an invalid value it does not return a 400 telling the client "id must be a number"; it returns a silent 404, or the request goes to a neighboring action you did not expect. If you want to validate input, do it with model validation.

Trying to use conventional routing with [ApiController]. When I left an [ApiController] action without an attribute route next to a definition like app.MapControllerRoute("default", "{controller}/{action=Index}/{id?}"), the application did not even start: Action methods on controllers annotated with ApiControllerAttribute must be attribute routed. In a Web API, use attribute routing.

Making literals and parameters compete. Adding [HttpGet("count")] later while [HttpGet("{name}")] exists makes a product named count unreachable. Design your routes to be resource-oriented and distinct from each other.

Forgetting the leading /, or adding it by mistake. "/api/categories/..." skips the controller prefix, while "api/categories/..." is appended after the prefix and produces an address like api/Products/api/categories/....

A Small Experiment

★☆☆ Remove the min(1) constraint from GetById and read from the log which action the /api/products/0 request goes to now.

★★☆ Add an action that fetches products by SKU code. SKUs look like KB-1001; accept only that format with a regex constraint, and verify with /debug/routes and a few requests that it does not conflict with the {name} route.

★★★ Run the conflicting route example in a project and see the 500 response. Then build the project for the net10.0 target, see the ASP0023 warning with your own eyes, and resolve the conflict in three different ways.

Routing delivered the request to the right action. But what happens inside that action? In the next post we look at controllers and actions themselves: ControllerBase, what [ApiController] brings, the difference between IActionResult and ActionResult<T>, and a complete CRUD skeleton backed by an in-memory list.