Endpoint Routing
Explain how UseRouting selects an endpoint, read endpoint metadata in middleware with GetEndpoint and predict which route wins.
app.MapGet("/orders/{id:int}", handler).WithMetadata(new AuditedAttribute());
var audited = context.GetEndpoint()?.Metadata.GetMetadata<AuditedAttribute>();Endpoint routing splits request handling in two. The routing middleware (UseRouting) matches the request against every registered endpoint and stores the winner on the HttpContext. The endpoint middleware (UseEndpoints, added for you at the end of a WebApplication pipeline) later executes it. Everything registered between the two can see which endpoint will run and its metadata, which is how CORS, authorization, rate limiting, request timeouts and output caching apply per-endpoint policies.
Endpoints and metadata
An Endpoint is a RequestDelegate plus a display name plus an EndpointMetadataCollection. A RouteEndpoint also carries its RoutePattern and an Order. Calls such as RequireAuthorization(), WithName(), WithTags() and attributes on handlers all add metadata objects. You can add your own with WithMetadata(new AuditedAttribute()) and read it in middleware with context.GetEndpoint()?.Metadata.GetMetadata<AuditedAttribute>(). GetMetadata<T> returns the last added instance, so a value set on an endpoint overrides one set on its group.
Where UseRouting goes
If you do not call UseRouting, WebApplication adds it at the very start, before your middleware. That is fine for most apps. Call it explicitly when something must run before matching: a middleware that rewrites Request.Path, UseForwardedHeaders, or static files that should never pay for route matching. Middleware placed before UseRouting gets null from GetEndpoint().
Which route wins
Matching is not first-registered-wins. The matcher builds a decision tree from all routes and orders candidates by Order (lower first, default 0) and then by template precedence, segment by segment:
| Segment kind | Example | Precedence |
|---|---|---|
| Literal | /orders/latest | Highest |
| Parameter with constraint | /orders/{id:int} | Higher |
| Parameter | /orders/{name} | Lower |
| Catch-all | /orders/{**rest} | Lowest |
After the path, HTTP method and other policies filter candidates. If the path matches but no endpoint accepts the method, routing selects a synthetic 405 HTTP Method Not Supported endpoint, so clients get 405 rather than 404.
Production failure modes
- Ambiguous routes fail per request, not at start-up. Two endpoints with equal precedence and the same method, such as
/orders/{id}and/orders/{number}, build fine, then every matching request throwsAmbiguousMatchExceptionand returns 500. An integration test that hits each route catches it. - Middleware placed before routing cannot see endpoint metadata, so a custom policy check silently never fires.
- Route tables are built lazily on the first request, so a bad constraint or a missing constraint registration appears as 500s on all traffic after deployment rather than as a start-up crash. A readiness probe that hits a real route surfaces it before traffic shifts.
Example
When to use it
- Read a custom [Audited] metadata object in middleware and write an audit record only for endpoints that carry it.
- Serve GET /orders/latest and GET /orders/{id:int} side by side without the literal ever being parsed as an id.
- Rewrite legacy /api/v1 paths before matching by calling UseRouting explicitly after the rewrite middleware.
More examples
Rewriting the path before routing runs
Calling <code>UseRouting</code> explicitly moves matching to that point in the pipeline. The rewrite middleware runs first, so <code>/api/v1/orders/7</code> matches the <code>/api/orders/{id:int}</code> endpoint. Without the explicit call, WebApplication would add routing at the start and the rewrite would come too late.
var builder = WebApplication.CreateSlimBuilder(args);
var app = builder.Build();
// Old mobile clients still call /api/v1/...; map them onto the current routes.
// This must run before UseRouting, otherwise matching already happened on the old path.
app.Use(async (context, next) =>
{
if (context.Request.Path.StartsWithSegments("/api/v1", out var remainder))
{
context.Request.Path = "/api" + remainder;
}
await next(context);
});
app.UseRouting();
app.MapGet("/api/orders/{id:int}", (int id) => TypedResults.Ok(new { id, status = "Paid" }));
app.Run();Pitfall: two templates with equal precedence
The matcher compares templates by precedence and <code>Order</code>; when two candidates tie, it cannot choose and throws for that request. Constraints or literal segments separate them; <code>WithOrder</code> is the last resort for two templates that really have equal precedence. Because the failure only appears when a request arrives, an integration test that calls every route is the reliable guard.
var builder = WebApplication.CreateSlimBuilder(args);
var app = builder.Build();
// Wrong: both are "/invoices/{parameter}" with GET, so every request throws
// AmbiguousMatchException ("The request matched multiple endpoints") and returns 500.
// app.MapGet("/invoices/{id}", (string id) => ...);
// app.MapGet("/invoices/{number}", (string number) => ...);
// Right: make the shapes distinguishable. A Guid constraint outranks a bare parameter,
// and the invoice number format is explicit in the second template.
app.MapGet("/invoices/{id:guid}", (Guid id) => TypedResults.Ok(new { id }));
app.MapGet("/invoices/by-number/{number}", (string number) => TypedResults.Ok(new { number }));
// A catch-all already ranks below every other template, so no Order is needed.
app.MapGet("/invoices/{**path}", (string path) => TypedResults.NotFound());
app.Run();Listing every endpoint for a start-up smoke check
<code>EndpointDataSource</code> from DI exposes every endpoint the app mapped, with its pattern and metadata. Logging the table at start-up, or asserting on it in a test, catches a route that a refactor dropped or a missing name that <code>LinkGenerator</code> and <code>CreatedAtRoute</code> depend on.
var builder = WebApplication.CreateSlimBuilder(args);
var app = builder.Build();
app.MapGet("/orders/{id:int}", (int id) => TypedResults.Ok(new { id })).WithName("GetOrder");
app.MapPost("/orders", (CreateOrder order) => TypedResults.Created("/orders/1001", order)).WithName("CreateOrder");
app.Lifetime.ApplicationStarted.Register(() =>
{
var source = app.Services.GetRequiredService<EndpointDataSource>();
foreach (var endpoint in source.Endpoints.OfType<RouteEndpoint>())
{
var methods = endpoint.Metadata.GetMetadata<IHttpMethodMetadata>()?.HttpMethods ?? ["*"];
var name = endpoint.Metadata.GetMetadata<IEndpointNameMetadata>()?.EndpointName ?? "(unnamed)";
app.Logger.LogInformation("Route {Methods} {Pattern} named {Name}",
string.Join(",", methods), endpoint.RoutePattern.RawText, name);
}
});
app.Run();
public record CreateOrder(string CustomerId, decimal Total);
Discussion