Model Binding
By the end of this lesson
Turn route values, query strings and request bodies into typed objects.
Every value in an HTTP request is text. The path is text, the query string is text, the body is a stream of bytes. Model binding is the step that turns that into the typed arguments your handler declares.
It runs before your code. By the time the first line of your method executes, binding has already succeeded. If it could not succeed, the request has already been answered with a 400 and your method was never called.
Knowing that changes how you debug. A 400 you cannot find in your own code is usually binding, and looking for it in your validation rules is looking one step too late.
Where a value can come from:
- Route values
- Segments matched by the route template. A request for /api/employees/482 against the template with id gives id the value 482.
- Query string
- Everything after the question mark. The natural home for optional filters, sorting and paging.
- Headers
- Bound with the FromHeader attribute. Metadata about the call rather than the data being sent, so keep domain values out of them.
- The body
- The request payload, read once and deserialized. JSON by default, and the source for the object a POST or PUT is sending. On a controller marked with the ApiController attribute, and in a minimal API handler, a complex parameter type is inferred as coming from here, which is what lets most handlers omit FromBody entirely.
- Form fields
- A posted HTML form or a file upload, bound with the FromForm attribute. A different content type from JSON, handled by a different formatter.
- Services
- Not part of the request at all. A parameter that is a registered service is resolved from dependency injection — automatically in a minimal API handler, and with the FromServices attribute in a controller action.
For a parameter of a simple type — a number, a string, a bool, a date — with no attribute on it, binding looks in three places in order and stops at the first match. Headers and the body are not among them, because reading the body implicitly would consume a stream something further down may need.
- Form fields, if the request has a form body
- Route values
- The query string
[ApiController]
[Route("api/employees")]
public sealed class EmployeesController(EmployeeService employees) : ControllerBase
{
// GET /api/employees/search?department=FIN&page=2&pageSize=50
[HttpGet("search")]
public Task<IReadOnlyList<Employee>> Search([FromQuery] EmployeeSearch search) =>
employees.SearchAsync(search);
// POST /api/employees/482/absences
[HttpPost("{id:int}/absences")]
public async Task<ActionResult<AbsenceResponse>> AddAbsence(
int id,
[FromBody] CreateAbsenceRequest request,
[FromHeader(Name = "X-Request-Id")] string? requestId,
CancellationToken ct)
{
var absence = await employees.AddAbsenceAsync(id, request, requestId, ct);
return Ok(AbsenceResponse.From(absence));
}
}
public sealed class EmployeeSearch
{
public string? Department { get; set; }
public int Page { get; set; } = 1;
public int PageSize { get; set; } = 25;
}- FromQuery on a complex type binds each of its properties from the query string by name. One parameter instead of five, and the defaults live on the class where they can be read.
- id carries no attribute and is a simple type, so binding finds it in the route values, which is where the template put it.
- FromBody states that this parameter is the payload. The inference would have reached the same conclusion here, so writing it is a choice: explicit intent at the cost of a little noise.
- FromHeader with an explicit name handles headers whose names are not valid C# identifiers. The nullable type says the header is optional — a non-nullable string would be treated as required and produce a 400 when the caller omits it.
- CancellationToken is not bound from any part of the message. It comes from the request's abort signal, so it is cancelled if the caller disconnects.
- Nothing in this method checks whether id is a number or whether the body parsed. Those questions were answered before it was called.
POST /api/employees/482/absences HTTP/1.1
Content-Type: application/json
{ "startDate": "not-a-date", "days": 2 }
HTTP/1.1 400 Bad Request
Content-Type: application/problem+json
{
"title": "One or more validation errors occurred.",
"status": 400,
"errors": {
"$.startDate": [
"The JSON value could not be converted to System.DateOnly."
]
}
}- The status is 400 because the caller sent something that cannot become the declared type. Nothing failed in your code, so 500 would be wrong.
- The content type is application/problem+json, a standard shape for error bodies. Callers can read it the same way for every endpoint in the API.
- The key is a path into the JSON document rather than a property name, because the failure happened while reading the document.
- Only one error is reported. JSON deserialization stops at the first value it cannot convert, so fixing that one can reveal the next. Values bound from the route and query string are all checked, so those failures are reported together.
- This response was produced by the ApiController convention. Without that attribute, the failure would sit in model state and your action would run with a partially bound object, which is a considerably worse default.
Summary
- Binding turns route values, query strings, headers, form fields and the body into typed arguments before your code runs
- A simple type with no attribute is looked for in form fields, then route values, then the query string
- Complex types are inferred as coming from the body on ApiController controllers and in minimal API handlers
- Only one parameter can be bound from the body, because the body is read once
- A value that cannot be converted produces a 400 with a problem details body, and your handler is never called
Practice
Attempt each one before opening the solution. Getting it wrong first is how the idea sticks.
Try it yourself
Three calls, three outcomes
Add the search endpoint above to a project, with PageSize defaulting to 25 on the class.
Call it three times: with no query string at all, with pageSize=100, and with pageSize=abc. Record what the handler receives, or what the caller receives instead.
Show solution
With no query string, the object is created and every property keeps its initialised value, so PageSize is 25. Binding only sets properties it found values for, which is why defaults on the class survive.
With pageSize=100, the property is 100. Note what did not happen: nothing rejected a page size of 100, because binding converts values and does not judge them. If 100 is too large, that is a validation rule you have to write.
With pageSize=abc, the caller gets a 400 with a problem details body naming pageSize, and your method is never called. The difference between this case and the previous one is the whole distinction between binding and validation.
The useful habit is separating the two questions when something goes wrong: could this value become the type, and should this value be allowed? They fail at different stages and are fixed in different places.
Challenge
One body, two things to send
A caller needs to send an updated employee record and a free-text reason for the change in one POST, and the reason must be recorded in the audit log.
Design the parameters. Then say what you would do differently if the reason were needed by middleware that runs before the endpoint.
Show solution
The straightforward answer is one request type containing both: an Employee property and a Reason property, bound from the body as a single parameter. Two body parameters are impossible, because the body is read once.
A wrapper type has a real cost worth naming. It couples two things that change for different reasons, and the audit reason now appears in the documentation of the employee payload. For one endpoint that is a fair trade; across twenty endpoints that each need a reason, it is repetition.
If middleware needs the reason, the body is the wrong place entirely. Middleware would have to read and rewind the stream, which costs a buffer on every request. A header carries it instead, available from the moment the request arrives and readable without touching the body.
That is the general rule this exercise is pointing at: data belongs in the body, and metadata about the call belongs in a header. The reason for a change sits awkwardly between the two, which is why this decision is worth making deliberately rather than by habit.
Knowledge check
Nothing is recorded and there is no score. The explanation appears either way.
Saved in this browser only.