Routing & Endpoint Resolution
How ASP.NET Core matches an incoming URL to your controller action β and what happens when two routes conflict.
ASP.NET Core routing matches incoming HTTP requests to endpoint handlers using compiled route trees. It's more than pattern matching β constraints, precedence rules, and endpoint metadata all play a role. Understanding how the router selects a winner (and throws when it can't decide) is essential for building APIs that behave predictably under all inputs.
At startup, all routes are collected from controllers (via MapControllers) and Minimal API definitions. Each route template is compiled into a RoutePattern β a tree structure that enables O(1) or O(log n) matching rather than linear scan.
When a request arrives, the router walks the route tree. Templates with constraints (like {id:int}) eliminate candidates early. The {id:int} constraint means 'match any segment AND successfully parse it as an integer'. A segment like 'abc' is rejected before the action is considered.
If multiple routes match, ASP.NET Core uses a precedence algorithm: literal segments > constrained parameters > unconstrained parameters > catch-all. If two routes have identical precedence, an AmbiguousMatchException is thrown at runtime β not at startup.
The matched endpoint carries metadata: [Authorize] attributes, accepted HTTP methods, response types, OpenAPI descriptions. This is why UseAuthorization must come after UseRouting β the auth middleware reads this metadata to decide whether to allow the request.
Route parameter names must match action parameter names (or [FromRoute(Name='...')] explicitly). The route value 'id' from {id:int} automatically binds to the int id parameter. Case is insensitive but naming must match.
Key Concepts
Pattern like 'api/users/{id:int}'. Literal segments must match exactly. Parameter segments ({id}) capture values. Constraints (:int, :guid) restrict what values are accepted.
{id:int} β must parse as int. {id:int:min(1)} β int AND >= 1. {name:alpha} β letters only. {id:guid} β valid GUID. Custom constraints via IRouteConstraint.
[HttpGet("{id:int}")] on controller actions. Explicit, predictable, and doesn't depend on controller/action naming conventions. Required for [ApiController].
app.MapGroup("/api/users") creates a group where all child routes share the prefix, plus shared middleware, auth policy, and metadata. ASP.NET Core 7+.
{**slug} matches everything including slashes. Must be the last segment. They block all routes defined after them that share the same prefix β a common source of hard-to-find bugs.
Thrown when two routes match with equal precedence. Not thrown at startup β only when the ambiguous URL is actually requested. Can hide in production for months.
1// Attribute routing β the preferred approach for APIs2[ApiController]3[Route("api/[controller]")] // Resolves to "api/users"4public class UsersController : ControllerBase5{6 // GET api/users7 [HttpGet]8 public IActionResult GetAll() => Ok();910 // GET api/users/42 β {id:int} constraint ensures int-only11 [HttpGet("{id:int}")]12 public IActionResult GetById(int id) => Ok();1314 // GET api/users/search?q=alice&page=215 [HttpGet("search")]16 public IActionResult Search([FromQuery] string q, [FromQuery] int page = 1) => Ok();1718 // PUT api/users/42/roles/admin β multiple route params19 [HttpPut("{id:int}/roles/{roleName:alpha}")]20 public IActionResult AssignRole(int id, string roleName) => Ok();21}2223// Minimal API with route groups (ASP.NET Core 9)24var users = app.MapGroup("/api/users")25 .RequireAuthorization()26 .WithTags("Users");2728users.MapGet("/", async (IUserService _userService, CancellationToken cancellationToken)29 => await _userService.GetAllAsync(cancellationToken));3031users.MapGet("/{id:int}", async (int id, IUserService _userService, CancellationToken cancellationToken)32 => await _userService.GetByIdAsync(id, cancellationToken) is { } user33 ? Results.Ok(user)34 : Results.NotFound());3536// Route constraint examples37// {id:int} β must be parseable as int38// {id:int:min(1)} β int AND >= 139// {name:alpha} β letters only40// {slug:regex(^[a-z0-9-]+$)} β custom regex41// {id:guid} β must be a valid GUID
Routing bugs are among the hardest to diagnose because they're input-dependent β your happy-path tests pass, but a specific URL pattern causes a 500 in production months later. AmbiguousMatchException, catch-all route conflicts, and constraint misuse are all production incidents waiting to happen if you don't understand the matching rules.
Common Pitfalls
1AmbiguousMatchException Reaching Production
Your API has GET /api/users/{id:int} (controller A) and GET /api/users/{username} (controller B). Everything works in development. Three months later, a user with a numeric username triggers an AmbiguousMatchException in production.
Both routes match when the segment is a number (like '42'). {id:int} matches because '42' is a valid int. {username} matches because it's unconstrained. ASP.NET Core precedence: constrained params > unconstrained. But both routes had been accidentally given the same precedence through a refactor that added a global route prefix.
Make routes unambiguous by design. Use separate path prefixes: /api/users/by-id/{id:int} vs /api/users/by-name/{username}. Or add an :alpha constraint to {username:alpha} to exclude numeric-only values. Write a startup test that enumerates all routes and asserts no duplicates.
Takeaway: AmbiguousMatchException is a runtime error that only fires when the ambiguous URL is hit. Write integration tests for every route pattern in your API, not just the happy path β the exception won't surface until a specific input hits both routes simultaneously.
2Catch-All Route Blocking Downstream Endpoints
You add a catch-all route /files/{**path} for a file-serving feature. Suddenly /files/upload stops working β it was a separate POST endpoint, but now the catch-all intercepts GET requests to /files/upload and returns 405 Method Not Allowed instead of 404.
The catch-all {**path} has the lowest route precedence, but once matched, the endpoint's [HttpGet] constraint rejects the POST method. The POST to /files/upload matched the catch-all (because literal 'upload' was behind it), not the dedicated upload endpoint.
Register more-specific routes before catch-all routes when using convention routing. With attribute routing, explicitly scope the catch-all: [HttpGet("files/{**path}")] and ensure [HttpPost("files/upload")] has higher precedence. Test by listing all registered endpoints via app.Services.GetRequiredService<EndpointDataSource>().
Takeaway: Catch-all routes are greedy and their interactions with method constraints are non-obvious. Any route that shares a prefix with a catch-all needs explicit verification. Log all registered endpoints at startup in development to catch these conflicts early.
3Route Constraint Not Validating Range
You use {id:int} thinking it validates that the ID is a positive number. A user passes id=0 or id=-1 and your service throws an InvalidOperationException because it queries the database with an invalid ID. The constraint didn't protect you.
{id:int} only validates that the segment can be parsed as an int. It does NOT validate the value range. -2147483648 is a valid int and passes the constraint. Your business logic expected a positive value but got garbage, and the error is a 500 rather than a clean 400.
Use {id:int:min(1)} to enforce minimum value at the routing level. Alternatively, add model validation with [Range(1, int.MaxValue)] on your action parameter. For IDs specifically, consider using {id:guid} if you're on GUIDs β they're globally unique and unguessable, eliminating the range validation problem entirely.
Takeaway: Route constraints are for route matching disambiguation, not for business rule validation. Don't rely on :int to validate your business domain rules. Use DataAnnotations or FluentValidation for value-level validation β constraints only get you type-parsing.