Route Templates and Constraints

Write route templates with constraints, optional, default and catch-all parameters, add a custom IRouteConstraint and generate links with LinkGenerator.

Syntaxapp.MapGet("/customers/{customerId:int}/orders/{orderId:guid}", ...).WithName("GetOrder"); links.GetPathByName("GetOrder", new { customerId = 7, orderId });

A route template is a path pattern made of literal segments and parameters in braces: /customers/{customerId:int}/orders/{orderId:guid}. Routing parses it once into a RoutePattern, and parameters become route values that bind to handler arguments by name.

Parameter forms

FormMeaning
{id}Required, any non-empty segment
{id?}Optional; the handler parameter must be nullable or have a default
{page=1}Default value when the segment is missing
{*path}Catch-all, captures the rest including slashes; when generating links, slashes are encoded as %2F
{**path}Catch-all; slashes are kept as / when generating links

Constraints

Built-in constraints include int, long, bool, decimal, double, guid, datetime, alpha, minlength(n), maxlength(n), length(a,b), min(n), max(n), range(a,b), regex(...), required and file/nonfile. They chain: {qty:int:range(1,100)}. Numeric and date constraints use the invariant culture.

Constraints exist to disambiguate routes, not to validate input. A request that fails every constraint matches nothing and gets 404, which tells a client the resource does not exist when the real problem is a bad value. Use constraints to separate /products/{id:int} from /products/{slug}; validate business rules such as "quantity at most 100" in the handler or with validation and return 400.

Regex and the slim builder

Inside a route template, braces in a regex must be doubled, because the template parser reads single braces as parameter delimiters: {code:regex(^[A-Z]{{3}}-[0-9]{{4}}$)}. CreateSlimBuilder and AddRoutingCore leave out the regex constraint so that trimmed and AOT apps do not carry the regex engine. The route table is built on the first request, so the failure is not a start-up crash: every request to the app returns 500 with A route parameter uses the regex constraint, which isn't registered. Register it with RouteOptions.SetParameterPolicy<RegexInlineRouteConstraint>("regex"), or prefer a custom constraint with a [GeneratedRegex]. Also note that regex constraints match case-insensitively: ^[a-z]+$ accepts KET, which can make two routes ambiguous.

Custom constraints

Implement IRouteConstraint.Match and register a name in RouteOptions.ConstraintMap. Keep Match cheap and side-effect free: it runs during matching for every candidate request, and it also runs during link generation (RouteDirection.UrlGeneration). Never query a database from a constraint.

Generating links

Hard-coded URLs drift from routes. Name the endpoint with WithName("GetOrder") and inject LinkGenerator: GetPathByName("GetOrder", new { id }) returns /orders/42, and values that are not route parameters become the query string. GetUriByName(httpContext, ...) produces an absolute URL using the request's scheme and host, which is only right behind a proxy when forwarded headers are configured, and only safe when host filtering (the top-level AllowedHosts setting, or AddHostFiltering/UseHostFiltering with the slim builder) rejects a forged Host. TypedResults.CreatedAtRoute uses the same mechanism and also writes an absolute Location. Build password-reset links from a configured public base URL instead.

Example

Try it yourself
Loading editor…
Press Run to execute the code.

When to use it

  • Serve /products/KET-0042 by SKU and /products/blue-kettle by slug from two endpoints separated by a custom sku constraint.
  • Return a Location header for a new order built from the GetOrder route name instead of a hand-concatenated string.
  • Fix a CreateSlimBuilder app whose every request returned 500 after a regex constraint was added to one route.

More examples

Pitfall: using a constraint as validation

A failed constraint removes the endpoint from the candidates, so the client sees 404 with no explanation and support tickets say the cart vanished. Keeping the constraint to the type (<code>int</code>) still separates routes, while the range check becomes a 400 ValidationProblem that names the field and the rule.

Example · csharp
var builder = WebApplication.CreateSlimBuilder(args);
var app = builder.Build();

// Wrong: a client sending quantity 250 gets 404 Not Found, as if the cart did not exist.
// app.MapPut("/carts/{cartId:guid}/lines/{sku}/quantity/{qty:int:range(1,100)}", ...);

// Right: the constraint only says "this segment is a number"; the business rule returns 400.
app.MapPut("/carts/{cartId:guid}/lines/{sku}/quantity/{qty:int}",
    (Guid cartId, string sku, int qty) =>
        qty is < 1 or > 100
            ? Results.ValidationProblem(new Dictionary<string, string[]>
              {
                  ["qty"] = ["Quantity must be between 1 and 100."]
              })
            : Results.Ok(new { cartId, sku, qty }));

app.Run();

Optional segments and defaults need matching parameters

If an optional route parameter maps to a non-nullable handler parameter, minimal APIs return 400 when the segment is missing, because there is no value to bind. Default values in the template (<code>{pageSize:int=25}</code>) are applied before binding and before constraints, so the default must satisfy the constraint too.

Example · csharp
var builder = WebApplication.CreateSlimBuilder(args);
var app = builder.Build();

// {status?} is optional, so the parameter must be nullable.
// {pageSize=25} has a default, so the parameter can be a plain int.
app.MapGet("/customers/{customerId:int}/orders/{status?}",
    (int customerId, string? status) =>
        TypedResults.Ok(new { customerId, status = status ?? "any" }));

app.MapGet("/products/catalog/{pageSize:int=25}",
    (int pageSize) => TypedResults.Ok(new { pageSize }));

// An optional parameter is only optional as the last segment: "{status?}/recent" is accepted,
// but /orders/recent then returns 404, because the middle segment cannot be skipped.
app.Run();

Generating absolute URLs and Location headers

<code>CreatedAtRoute</code> and <code>GetUriByName</code> resolve the template from the endpoint name, so renaming a route segment cannot break links. <code>GetUriByName</code> and <code>CreatedAtRoute</code> both take scheme and host from the current request; behind a TLS-terminating proxy they produce <code>http://</code> links unless forwarded headers are configured, and a forged <code>Host</code> header reaches them unless host filtering is on.

Example · csharp
var builder = WebApplication.CreateSlimBuilder(args);
var app = builder.Build();

app.MapGet("/orders/{id:int}", (int id) => TypedResults.Ok(new Order(id, "Pending", 129.90m)))
   .WithName("GetOrder");

app.MapPost("/orders", (CreateOrder request) =>
{
    var order = new Order(1001, "Pending", request.Total);
    // Location: https://<request host>/orders/1001, an absolute URL built from the named route.
    return TypedResults.CreatedAtRoute(order, "GetOrder", new { id = order.Id });
});

app.MapGet("/orders/{id:int}/links", (int id, HttpContext context, LinkGenerator links) =>
    TypedResults.Ok(new
    {
        self = links.GetUriByName(context, "GetOrder", new { id }),
        invoice = links.GetUriByName(context, "GetOrder", new { id, format = "pdf" })
    }));

app.Run();

public record CreateOrder(string CustomerId, decimal Total);
public record Order(int Id, string Status, decimal Total);

Discussion

  • Be the first to comment on this lesson.