Routing and model binding
Route templates decide which endpoint runs, and the binding rules decide what it receives. Most "parameter is always null" bugs live in the second part.
Route templates and constraints
var app = builder.Build();
// Literal segments, parameters and constraints
app.MapGet("/articles/{id:int}", (int id) => Results.Ok(new { id }));
app.MapGet("/articles/{slug:slug}", (string slug) => Results.Ok(new { slug }));
app.MapGet("/files/{*path}", (string path) => Results.Text(path));
app.MapGet("/archive/{year:int?}", (int? year) =>
Results.Ok(new { year = year ?? DateTime.UtcNow.Year }));
// Multiple constraints on one parameter
app.MapGet("/orders/{id:guid:required}", (Guid id) => Results.Ok(id));
// Route groups keep a prefix and shared metadata in one place
var api = app.MapGroup("/api/v2")
.RequireAuthorization()
.WithTags("v2");
api.MapGet("/health", () => Results.Ok(new { status = "ok" }));
api.MapPost("/orders", PlaceOrder).Produces<Order>(StatusCodes.Status201Created);
// Link generation without hard-coded strings
app.MapGet("/articles/{id:int}", (int id, LinkGenerator links) =>
Results.Ok(links.GetPathByName("GetArticle", new { id })))
.WithName("GetArticle");| Constraint | Matches | Note |
|---|---|---|
int, long, guid | The type's default parse rules | A failed constraint means 404, not 400 |
min(1) | A value at or above the bound | Compose with a type: {id:int:min(1)} |
length(2,10) | A string of that length | Applied to the raw segment |
regex(...) | A regular expression | Anchor it yourself; it is not anchored |
alpha | Letters only | Locale independent |
slug | Lower-case words separated by hyphens | Not the same as your content's slug rules |
required | The parameter must be present | Useful with optional segments |
- Route matching happens before binding, so a constraint failure produces 404 even though the resource exists with a different identifier format.
- Route parameters are not case sensitive by default, which surprises people who expect URLs to be case sensitive.
- A catch-all parameter
{*path}can be empty but cannot contain the separator unless you also enable encoded slashes. - Ambiguity between two endpoints that match the same URL is detected at startup in recent versions, which is a much better place to find it.
Where a value comes from
// The binding sources, in the order minimal APIs try them for a simple type:
// route -> query string -> header (only with an explicit attribute)
// For a complex type, the body is tried unless the type is registered in DI.
public record CreateOrder(string Sku, int Qty);
app.MapPost("/orders", (
CreateOrder body, // from the JSON body
[FromRoute] int? id, // explicit route source
[FromQuery] string? currency, // explicit query source
[FromHeader(Name = "Idempotency-Key")] string? key,
[FromServices] IOrderWriter writer,
CancellationToken ct) =>
{
return Results.Created("/orders/1", new { body.Sku, currency, key });
});
// Bind a form or a non-JSON content type explicitly
app.MapPost("/legacy", async (HttpRequest request) =>
{
var form = await request.ReadFormAsync();
var sku = form["sku"].ToString();
return Results.Ok(new { sku });
});
// A domain type that binds directly from a route or query parameter
public readonly record struct Sku(string Value) : IParsable<Sku>
{
public static Sku Parse(string s, IFormatProvider? provider) => new(s);
public static bool TryParse(string? s, IFormatProvider? provider, out Sku result)
{
result = default;
if (string.IsNullOrWhiteSpace(s) || s.Length > 40) return false;
result = new Sku(s);
return true;
}
}
app.MapGet("/sku/{sku}", (Sku sku) => Results.Ok(sku.Value));- A complex type is read from the body only if the request has a body and the content type is JSON. A GET with a complex parameter silently binds to nothing.
- Minimal APIs do not bind simple types from headers implicitly; an explicit
[FromHeader]is required, and the name defaults to the parameter name. CancellationToken,HttpContext,HttpRequestandHttpResponseare injected automatically.- A parameter that implements
IParsable<T, TSelf>binds from the route or query using itsTryParse. - Never bind a domain entity directly from the body if it has properties the caller must not set — that is mass assignment with extra steps.
- Optional route parameters must also be nullable in the handler or the binding will supply a default and hide a missing segment.
⚠️
If a parameter is null and you cannot see why, log the raw values. A complex type on a GET, a body sent as
application/x-www-form-urlencoded, or a JSON property name that does not match the constructor parameter are the three causes worth checking first.FAQ
Should I use route or query parameters?
Route parameters for identity, query parameters for filtering and options. Mixing the two roles makes caching and link generation harder to reason about.
How do I make a route parameter optional but still validated?
Use
{year:int?} and take an int? in the handler. A constraint still applies when the segment is present, and the null case is explicit.Related
Minimal APIs Model validation and Problem Details responses
Last refreshed 2026-09-18.