Routing
By the end of this lesson
Map URLs to handlers, including route parameters and constraints.
Routing answers one question: which piece of my code should handle this request? It happens in two stages. The routing middleware compares the request against every registered route template and records the endpoint it chose. Later, at the end of the pipeline, the endpoint middleware runs that endpoint.
The gap between those two stages is useful rather than incidental. Middleware registered between them can see which endpoint was selected and read the metadata attached to it, which is how per-endpoint authorization and CORS policies work at all.
Both URL segments and the HTTP method take part in matching. GET and POST on the same path are two different endpoints.
[ApiController]
[Route("api/employees")]
public sealed class EmployeesController(EmployeeService employees) : ControllerBase
{
// GET /api/employees?page=2
[HttpGet]
public Task<IReadOnlyList<Employee>> List([FromQuery] int page = 1) =>
employees.ListAsync(page);
// GET /api/employees/482
[HttpGet("{id:int}")]
public Task<Employee?> GetById(int id) => employees.FindAsync(id);
// GET /api/employees/by-code/FIN
[HttpGet("by-code/{code:alpha:length(3)}")]
public Task<IReadOnlyList<Employee>> ByDepartmentCode(string code) =>
employees.ListByDepartmentAsync(code);
// GET /api/employees/482/absences/2024
[HttpGet("{id:int}/absences/{year:int:range(2000,2100)}")]
public Task<IReadOnlyList<Absence>> Absences(int id, int year) =>
employees.ListAbsencesAsync(id, year);
// GET /api/employees/me
[HttpGet("me")]
public Task<Employee?> Current() => employees.FindCurrentAsync(User);
}- The Route attribute on the class sets a prefix, and each method's template is appended to it. Change the prefix once and every endpoint in the class moves.
- A segment in braces is a route parameter. Its name has to match the method parameter, and the value arrives converted to that parameter's type.
- A colon inside the braces adds a constraint. The template with id:int only matches when that segment is an integer, so a request for /api/employees/abc does not match this endpoint at all, rather than matching and failing later.
- Constraints can be chained. The code:alpha:length(3) template requires three letters, so FIN matches and F1 does not.
- The literal segment me sits alongside the id:int template without conflict, because a literal is more specific than a parameter and wins when both could match.
- These actions return their values directly to keep the focus on templates. The next lesson covers returning status codes properly, which is what real endpoints do.
Constraints worth knowing. A constraint is a matching rule rather than validation: it decides whether this route applies, so a value that fails one produces a 404 rather than a message about the value.
- {id:int}
- Matches integers only. There are equivalents for guid, bool, long, double, decimal and datetime, covering most identifier shapes.
- {page:int:min(1)}
- An integer of at least 1. There is also max and range for the two-sided case.
- {code:alpha}
- Letters only. Combine with length, minlength or maxlength to pin the size, as the department code does above.
- {slug:regex(^[a-z0-9-]+$)}
- A regular expression, for shapes the built-in constraints do not cover. Readable in small doses and unreadable in large ones.
- {*path}
- A catch-all, matching the rest of the URL including slashes. It matches nearly everything, so it is always a last resort and never a first choice.
When more than one template could match a URL, the framework does not pick at random. It ranks candidates by how specific they are, segment by segment:
- A template with a different number of segments is not a candidate at all, unless it has optional parameters or a catch-all.
- A literal segment beats a parameter. For /api/employees/me, the me template wins over the id template.
- A constrained parameter beats an unconstrained one. The id:int template is preferred over a plain id template.
- A catch-all is the least specific and is considered last.
- If two candidates are still equally specific, the request fails rather than resolving. There is an Order property that can force a winner, and needing it is usually a sign the templates should be different instead.
Summary
- Routing matches first and executes later, which is why middleware in between can read endpoint metadata
- A route template is literal segments plus parameters, and parameters can carry constraints
- Constraints decide whether a route matches, so a failed constraint gives a 404 rather than an explanation
- Precedence is by specificity: literals beat constrained parameters, which beat plain parameters, which beat catch-alls
- Two equally specific matches raise an error instead of a guess, which surfaces a real design problem early
Practice
Attempt each one before opening the solution. Getting it wrong first is how the idea sticks.
Try it yourself
Cause an ambiguous match
Add a second action to a controller with exactly the same template and HTTP method as an existing one, then call that URL.
Read the exception. Then make the templates distinct and call both.
Show solution
The application starts without complaint and the request fails with an ambiguous match error naming both candidate endpoints.
The reason it waits until the request is that matching happens per request, against the URL. Nothing is wrong with the route table in isolation; it only becomes a problem when a URL arrives that two endpoints claim equally.
The useful habit is reading the exception rather than guessing. It lists the competing endpoints, which usually makes the duplicate obvious immediately.
Think about it
Two identifier shapes in one segment
An API has /api/employees/{id:int} and /api/employees/{code:alpha}. Numbers reach the first and letters reach the second, and both work.
A caller now needs the employee whose staff code is 4TH. What happens, and what does that tell you about the design?
Show solution
Neither template matches. The alpha constraint rejects the digit and the int constraint rejects the letters, so the caller gets a 404 for an employee who exists.
The design is leaking. Encoding two different kinds of identifier in the same segment means the route table is guessing which one a caller meant, and the guess is made by constraints that know nothing about your data.
Distinct paths are clearer: /api/employees/{id:int} for the surrogate key and /api/employees/by-code/{code} for the business identifier. It costs one more route and removes a class of 404s that look like missing data.
There is a defensible alternative. If the staff code is the only identifier callers should know, drop the integer route from the public surface entirely. The problem is having both in one place, not having either.
Knowledge check
Nothing is recorded and there is no score. The explanation appears either way.
Saved in this browser only.