Correct binding gives you typed, validated input instead of parsing HttpContext by hand.
Minimal API handlers declare what they need as parameters, and ASP.NET Core binds each one from the request. The rules, roughly in order:
- Explicit attributes win:
[FromRoute],[FromQuery],[FromHeader(Name = "X-Api-Key")],[FromBody],[FromForm],[FromServices]. - Special types are injected directly:
HttpContext,HttpRequest,HttpResponse,CancellationToken(canceled when the client disconnects),ClaimsPrincipal. - A parameter whose name matches a route parameter binds from the route.
- Simple types (
int,string,Guid,DateTime, enums, anything withTryParse) otherwise bind from the query string. Arrays likeint[] idsbind from repeated keys?ids=1&ids=2. - Types registered in DI bind from services.
- Any other complex type binds from the JSON body using
System.Text.Json(camelCase by default). Only one body parameter is allowed.
Nullability matters: string? q or a default value (int page = 1) makes a query parameter optional; a non-nullable one that is missing produces a 400.
[AsParameters] groups many parameters into one record or class, keeping handler signatures tidy.
Records are a natural fit for request bodies: record CreateTodo(string Title, DateOnly? Due);. Don't bind directly to database entities; use separate request models so clients can't set fields like Id or IsAdmin.
Example
using Microsoft.AspNetCore.Mvc;
var app = WebApplication.Create(args);
app.MapGet("/search", (string q, int page = 1, int[]? tags = null) =>
$"q={q} page={page} tags={string.Join(",", tags ?? [])}");
app.MapGet("/whoami", ([FromHeader(Name = "X-User")] string? user) =>
user ?? "anonymous");
app.MapPost("/todos", (CreateTodo body) =>
Results.Ok(new { body.Title, Due = body.Due?.ToString("yyyy-MM-dd") }));
app.MapGet("/orders/{id:int}", ([AsParameters] OrderQuery query) =>
$"order {query.Id}, details={query.Details}");
app.Run();
// GET /search?q=cs&tags=1&tags=2 -> q=cs page=1 tags=1,2
// GET /search -> 400 (q is required)
// POST /todos {"title":"Ship","due":"2026-10-20"} -> {"title":"Ship","due":"2026-10-20"}
// GET /orders/5?details=true -> order 5, details=True
record CreateTodo(string Title, DateOnly? Due);
record OrderQuery(int Id, bool Details = false);Remember
- Route names bind from the route, simple types from the query, complex types from the JSON body
- Nullable or defaulted parameters are optional; missing required ones give 400
- Use [FromHeader], [FromQuery] etc. to be explicit
- Bind to request records, not database entities
Watch out for
- Declaring two complex-type parameters and expecting both to come from the body.