Parameter Binding
Predict where each handler parameter comes from, bind route, query, header, body, form and services explicitly, and add custom TryParse and BindAsync types.
app.MapGet("/customers/{id:int}/orders", (int id, OrderStatus[] status,
[FromHeader(Name = "X-Tenant")] string tenant, IOrderRepository repo,
int page = 1) => ...);When an endpoint is built, the framework looks at each handler parameter once and decides its source. An attribute always wins. Without one, it infers in this order:
| Parameter | Source |
|---|---|
HttpContext, HttpRequest, HttpResponse, CancellationToken, ClaimsPrincipal, IFormFile, IFormFileCollection, Stream, PipeReader | Special types taken from the request |
Type with a static BindAsync(HttpContext[, ParameterInfo]) | The type binds itself |
string or TryParse type whose name matches a route parameter | Route |
Simple type: string, numbers, Guid, DateOnly, enums, anything with TryParse, and arrays of these | Query string (?status=a&status=b fills an array) |
| Type registered in DI | Services |
| Any other complex type | JSON body (at most one) |
[FromRoute], [FromQuery], [FromHeader(Name = "X-Tenant")], [FromBody], [FromForm] and [FromServices] (namespace Microsoft.AspNetCore.Mvc) override inference. Headers are never inferred. A tenant header is only a hint: check it against the authenticated user's tenant claim, or take the tenant from the claim, or any caller reads another tenant's data by editing the header.
Required, optional and failures
A non-nullable parameter without a default is required. int page with no ?page= is a 400; int? page or int page = 1 is optional. Unparsable values (?page=abc, an enum value in the wrong case) are 400, a body sent as text/plain is 415, and a missing JSON body for Order order is 400 (use Order? order to allow it). These 400s have an empty body and the reason is logged at Debug level. In Development, RouteHandlerOptions.ThrowOnBadRequest is on, so the reason shows on the developer exception page. Register AddProblemDetails() and call app.UseStatusCodePages() if clients need a ProblemDetails body.
Grouping parameters with [AsParameters]
A search endpoint can take ten inputs. [AsParameters] OrderSearch search binds each constructor parameter or settable property of OrderSearch as if it were a handler parameter, with the same inference and attributes. Prefer a record struct to avoid an allocation per request.
Custom types: TryParse and BindAsync
A type with public static bool TryParse(string? value, out T result) (optionally with an IFormatProvider) binds from a single route, query or header value, for example ?price=10-50 into a PriceRange. A type with public static ValueTask<T?> BindAsync(HttpContext context, ParameterInfo parameter) builds itself from the whole request, which suits paging defaults and clamping.
Form values
[FromForm] binds a complex type from form fields. In .NET 10 an empty form value for a nullable value type such as decimal? or DateOnly? binds to null instead of failing the request. Form binding also brings antiforgery requirements, covered in File Uploads and Form Data.
The trap: a forgotten registration
If PricingService is not registered, the parameter is no longer a service: it is inferred as the body. On a GET that throws Body was inferred but the method does not allow inferred body parameters when the endpoints are built, and every request to the app fails with 500. On a POST that already has a body it is a second body parameter, with the same result. [FromServices] on service parameters turns this into a clear error about the missing service.
Example
When to use it
- Bind GET /customers/42/orders?status=Shipped&status=Delivered&page=2 plus an X-Tenant header into one [AsParameters] search record.
- Parse ?price=10.00-49.90 into a PriceRange value with a static TryParse so handlers never split strings themselves.
- Clamp page size to 100 for every list endpoint with a Paging type that implements BindAsync.
More examples
BindAsync for paging with defaults and a ceiling
<code>BindAsync</code> sees the whole <code>HttpContext</code>, so one type can read several query keys and apply policy. Clamping <code>size</code> to 100 is the important part: without a ceiling, one client asking for <code>size=100000</code> pulls a huge result set through the database and the serializer. Returning a non-nullable <code>ValueTask<Paging></code> means the parameter can never be missing, so it never produces a 400.
using System.Reflection;
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
// GET /invoices?page=3&size=500 binds as page 3, size 100.
app.MapGet("/invoices", (Paging paging) =>
new { paging.Page, paging.Size, skip = paging.Skip });
app.Run();
public readonly record struct Paging(int Page, int Size)
{
private const int DefaultSize = 20;
private const int MaxSize = 100;
public int Skip => (Page - 1) * Size;
public static ValueTask<Paging> BindAsync(HttpContext context, ParameterInfo parameter)
{
var query = context.Request.Query;
var page = int.TryParse(query["page"], out var p) && p > 0 ? p : 1;
var size = int.TryParse(query["size"], out var s) && s > 0 ? Math.Min(s, MaxSize) : DefaultSize;
return ValueTask.FromResult(new Paging(page, size));
}
}Pitfall: an unregistered service becomes the body
Inference checks DI with <code>IServiceProviderIsService</code>, so a missing registration silently changes the parameter's source. With <code>[FromServices]</code> the source is fixed: a missing registration becomes a clear <code>InvalidOperationException</code> naming <code>PricingService</code> on that endpoint, instead of a misleading body inference error that takes down every route. Many teams mark service parameters explicitly in shared code for exactly this reason.
using Microsoft.AspNetCore.Mvc;
var builder = WebApplication.CreateBuilder(args);
// Remove this line and, without [FromServices], PricingService would be inferred as the JSON body.
// On a GET that throws "Body was inferred but the method does not allow inferred body parameters"
// when endpoints are built, and every request to the app returns 500.
builder.Services.AddSingleton<PricingService>();
var app = builder.Build();
app.MapGet("/products/{sku}/price", (string sku, [FromServices] PricingService pricing, string currency = "EUR") =>
TypedResults.Ok(pricing.Quote(sku, currency)));
app.Run();
public sealed class PricingService
{
public PriceQuote Quote(string sku, string currency) => new(sku, 39.90m, currency);
}
public record PriceQuote(string Sku, decimal Amount, string Currency);Form binding with empty values
Browsers send every field of a form, including the empty ones, so <code>MinTotal=</code> arrives as an empty string. Binding it to a nullable value type as null is what a form user means by leaving a field blank. A non-nullable <code>decimal</code> property would still fail with an empty value. Because a browser posts this form, the endpoint keeps antiforgery on with <code>AddAntiforgery</code> and <code>UseAntiforgery</code>; only an API called with bearer tokens, never cookies, should use <code>DisableAntiforgery()</code>.
using Microsoft.AspNetCore.Mvc;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddAntiforgery();
var app = builder.Build();
app.UseAntiforgery();
// An HTML filter form posts MinTotal= and Since= when the user leaves them blank.
// In .NET 10 those bind to null for nullable value types instead of failing with 400.
app.MapPost("/reports/orders", ([FromForm] OrderReportFilter filter) =>
TypedResults.Ok(new
{
filter.CustomerName,
minTotal = filter.MinTotal?.ToString("0.00") ?? "any",
since = filter.Since?.ToString("yyyy-MM-dd") ?? "any"
}));
// A browser form keeps antiforgery: the page renders the token in a hidden field,
// and [FromForm] binding makes UseAntiforgery validate it on every post.
app.Run();
public sealed class OrderReportFilter
{
public string CustomerName { get; set; } = "";
public decimal? MinTotal { get; set; }
public DateOnly? Since { get; set; }
}
Discussion