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");
ConstraintMatchesNote
int, long, guidThe type's default parse rulesA failed constraint means 404, not 400
min(1)A value at or above the boundCompose with a type: {id:int:min(1)}
length(2,10)A string of that lengthApplied to the raw segment
regex(...)A regular expressionAnchor it yourself; it is not anchored
alphaLetters onlyLocale independent
slugLower-case words separated by hyphensNot the same as your content's slug rules
requiredThe parameter must be presentUseful 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, HttpRequest and HttpResponse are injected automatically.
  • A parameter that implements IParsable<T, TSelf> binds from the route or query using its TryParse.
  • 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.

Minimal APIs Model validation and Problem Details responses

Last refreshed 2026-09-18.