AI WisdomArchitecture & guides β†—
HT
How Things Work

Routing & Endpoint Resolution

How ASP.NET Core matches an incoming URL to your controller action β€” and what happens when two routes conflict.

How It Works

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.

1
Endpoint Registration

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.

2
Route Matching with Constraints

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.

3
Ambiguity Resolution

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.

4
Endpoint Metadata

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.

5
Parameter Binding from Route

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

πŸ“Route Template

Pattern like 'api/users/{id:int}'. Literal segments must match exactly. Parameter segments ({id}) capture values. Constraints (:int, :guid) restrict what values are accepted.

πŸ”’Route Constraints

{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.

🏷️Attribute Routing

[HttpGet("{id:int}")] on controller actions. Explicit, predictable, and doesn't depend on controller/action naming conventions. Required for [ApiController].

πŸ“Route Groups

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+.

🌐Catch-All Parameters

{**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.

⚠️AmbiguousMatchException

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.

Attribute Routing & Minimal API Route Groups
tsx
1// Attribute routing β€” the preferred approach for APIs
2[ApiController]
3[Route("api/[controller]")] // Resolves to "api/users"
4public class UsersController : ControllerBase
5{
6 // GET api/users
7 [HttpGet]
8 public IActionResult GetAll() => Ok();
9
10 // GET api/users/42 β€” {id:int} constraint ensures int-only
11 [HttpGet("{id:int}")]
12 public IActionResult GetById(int id) => Ok();
13
14 // GET api/users/search?q=alice&page=2
15 [HttpGet("search")]
16 public IActionResult Search([FromQuery] string q, [FromQuery] int page = 1) => Ok();
17
18 // PUT api/users/42/roles/admin β€” multiple route params
19 [HttpPut("{id:int}/roles/{roleName:alpha}")]
20 public IActionResult AssignRole(int id, string roleName) => Ok();
21}
22
23// Minimal API with route groups (ASP.NET Core 9)
24var users = app.MapGroup("/api/users")
25 .RequireAuthorization()
26 .WithTags("Users");
27
28users.MapGet("/", async (IUserService _userService, CancellationToken cancellationToken)
29 => await _userService.GetAllAsync(cancellationToken));
30
31users.MapGet("/{id:int}", async (int id, IUserService _userService, CancellationToken cancellationToken)
32 => await _userService.GetByIdAsync(id, cancellationToken) is { } user
33 ? Results.Ok(user)
34 : Results.NotFound());
35
36// Route constraint examples
37// {id:int} β€” must be parseable as int
38// {id:int:min(1)} β€” int AND >= 1
39// {name:alpha} β€” letters only
40// {slug:regex(^[a-z0-9-]+$)} β€” custom regex
41// {id:guid} β€” must be a valid GUID
πŸ’‘
Why This Matters

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

⚠AmbiguousMatchException is thrown at REQUEST time, not at startup. You can have two conflicting routes registered for months and never know until a specific URL hits both.
⚠{id:int} does NOT validate range. id=-1 passes the constraint and reaches your action. Use {id:int:min(1)} or [Range(1, int.MaxValue)] for business validation.
⚠Catch-all parameters ({**slug}) must be the LAST segment and they shadow all more-specific routes registered with the same prefix if order isn't correct.
⚠Missing [Route] on a base controller class means attribute routing is undefined β€” [HttpGet] on actions without a controller-level [Route] will silently use empty prefix.
⚠Convention routing (MapControllerRoute) respects ORDER β€” the first matching template wins. Unlike attribute routing, there's no precedence algorithm. Routes defined second can be permanently unreachable.
Real-World Use Cases

1AmbiguousMatchException Reaching Production

Scenario

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.

Problem

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.

Solution

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

Scenario

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.

Problem

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.

Solution

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

Scenario

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.

Problem

{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.

Solution

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.