Reading the Request

How ASP.NET Core fills a handler's parameters from the route, query string, headers and body, and how to override its guess.

Syntaxapp.MapPost("/orders/{id:int}", (int id, [FromQuery] string? coupon, [FromBody] Order order) => ...);

An HTTP request arrives as text: a method, a path, some headers, and sometimes a body. Model binding is the step that turns that text into the parameters of your handler. You write int id, and ASP.NET Core finds the value, converts it, and calls your code.

Where each parameter comes from

Every parameter gets a binding source: the part of the request its value is read from. When you do not say which, the framework picks one.

The parameterSource chosen
Its name matches a {placeholder} in the routeRoute value
A string, a number, or any type with a static TryParseQuery string
A type registered in dependency injectionThat service
HttpContext, HttpRequest, CancellationToken, ClaimsPrincipal, IFormFileTaken from the context
Anything elseThe body, read as JSON

Saying it in full

Those guesses are a shorthand. The attributes [FromRoute], [FromQuery], [FromHeader], [FromBody], [FromForm] and [FromServices] state the same choice in longhand, and they beat the guess. A header is the one source never guessed, so it always needs [FromHeader]. Reach for [FromBody] when the guess is wrong, such as a string parameter that is meant to be the whole body.

What trips people up

A parameter is required unless its type is nullable or it has a default value. A missing one is a 400 and your handler never runs. A value that will not parse is also a 400, even for int?. A body sent as anything other than application/json is a 415.

Example

Example · csharp
using Microsoft.AspNetCore.Mvc;

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

// id matches {id} in the route, so it is read from the path.
// page is a number with no match in the route, so it is read from the query string.
// tenant is a header, which is never guessed, so it is named in full.
app.MapGet("/orders/{id:int}", (
    int id,
    int page,
    [FromHeader(Name = "X-Tenant")] string? tenant) =>
    TypedResults.Ok(new { id, page, tenant }));

// order is not a number, a service or a special type, so it comes from the body as JSON.
app.MapPost("/orders", (Order order) =>
    TypedResults.Created($"/orders/{order.Id}", order));

// The same two sources, written out instead of guessed. This compiles to the same binding.
app.MapPost("/orders/search", ([FromQuery] string term, [FromBody] Order order) =>
    TypedResults.Ok(new { term, order.Product }));

app.Run();

record Order(int Id, string Product, int Quantity);

When to use it

  • Read a page number from the query string and fall back to page 1 when the client omits it.
  • Accept a JSON body on a PUT while also reading a tenant id from a request header.
  • Collect a dozen filter parameters into one request type instead of a long parameter list.
  • Take the body as a raw stream for an endpoint that receives CSV rather than JSON.

More examples

One parameter instead of ten, with [AsParameters]

[AsParameters] binds each property of a type from the request, using the same rules as parameters. The two endpoints accept identical requests. Reach for it when a handler has grown a long parameter list, or when several endpoints share the same set of filters and you want to declare them once. A struct is used here because it avoids an allocation per request; a record or class binds the same way.

Example · csharp
using Microsoft.AspNetCore.Mvc;

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

// Longhand: every value is its own parameter.
app.MapGet("/search", (
    string term,
    int page,
    int pageSize,
    [FromHeader(Name = "X-Tenant")] string? tenant) =>
    TypedResults.Ok(new { term, page, pageSize, tenant }));

// Shorthand: the same four values, grouped into one type.
app.MapGet("/search2", ([AsParameters] SearchRequest request) =>
    TypedResults.Ok(new
    {
        request.Term,
        request.Page,
        request.PageSize,
        request.Tenant
    }));

app.Run();

// Each property binds by exactly the rules the parameters used.
struct SearchRequest
{
    public string Term { get; set; }
    public int Page { get; set; }
    public int PageSize { get; set; }

    [FromHeader(Name = "X-Tenant")]
    public string? Tenant { get; set; }
}

A custom type that binds itself with TryParse

Adding a static TryParse to your own type makes it bindable from the route or query string, which keeps the parsing in one place instead of repeated in every handler. Returning false produces a 400 for the caller. Use this for small value types that arrive as one string: a coordinate, a date range, a sort expression.

Example · csharp
using System.Globalization;

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

// Point is not a number and not a service, but it has a static TryParse,
// so the framework reads it from the query string: /nearest?location=51.5,-0.12
app.MapGet("/nearest", (Point location) =>
    TypedResults.Ok($"lat {location.Lat}, lon {location.Lon}"));

app.Run();

readonly struct Point
{
    public double Lat { get; init; }
    public double Lon { get; init; }

    // The framework looks for exactly this shape: name, format provider, out value.
    public static bool TryParse(string? value, IFormatProvider? provider, out Point point)
    {
        point = default;
        string[] parts = value?.Split(',') ?? [];

        if (parts.Length != 2
            || !double.TryParse(parts[0], NumberStyles.Float, provider, out double lat)
            || !double.TryParse(parts[1], NumberStyles.Float, provider, out double lon))
        {
            return false;
        }

        point = new Point { Lat = lat, Lon = lon };
        return true;
    }
}

The same work done by hand

Taking an HttpRequest parameter turns binding off for the rest of the handler: you read the values yourself and decide what a missing or malformed one means. Worth doing when the request does not fit the binding model, for example when the body format depends on a header, or when you need the raw bytes. Note that every conversion and every missing-value check is now yours.

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

// Binding is a convenience. This is what it saves you writing.
app.MapPost("/orders/{id:int}", async (HttpRequest request) =>
{
    int id = int.Parse((string)request.RouteValues["id"]!);
    string? coupon = request.Query["coupon"];
    Order? order = await request.ReadFromJsonAsync<Order>();

    if (order is null)
    {
        return Results.BadRequest("A JSON body is required.");
    }

    return Results.Ok(new { id, coupon, order.Product });
});

app.Run();

record Order(string Product, int Quantity);

Discussion

  • Be the first to comment on this lesson.