Model Binding and Validation in Controllers
Control where each action parameter binds from, read the automatic 400 that [ApiController] returns, and reshape it with InvalidModelStateResponseFactory.
public IActionResult Create(
[FromRoute] int customerId,
[FromBody] CreateOrder body,
[FromQuery] Paging paging,
[FromHeader(Name = "Idempotency-Key")] string key)Before an action runs, model binding fills its parameters from the request and validation records every problem in ModelState. With [ApiController] an invalid ModelState never reaches your code: the framework answers 400 itself.
Binding sources and inference
| Attribute | Reads from | Inferred by [ApiController] when |
|---|---|---|
[FromRoute] | Route values | The name matches a template parameter |
[FromQuery] | Query string | Any other simple type |
[FromBody] | Body, via an input formatter | A complex type |
[FromForm] | Form fields and files | IFormFile, IFormFileCollection |
[FromHeader] | A header | Never, always explicit |
[FromServices] | DI container | The type is registered as a service |
The body is read once, so only one parameter can bind from it; two inferred complex types fail at startup. A complex type meant for the query string (paging, filters) needs an explicit [FromQuery]. The input formatter is chosen by Content-Type: a missing or unsupported one is 415, an empty body for a required parameter is 400 with A non-empty request body is required.
What validation checks
Validation runs DataAnnotations attributes and IValidatableObject.Validate, recursing into nested objects and collections (keys such as address.line1). With nullable reference types on, a non-nullable string property is implicitly required: a missing, null, empty or whitespace-only value fails, because the inferred [Required] has AllowEmptyStrings = false. Declare the property as string? or use [Required(AllowEmptyStrings = true)] where an empty value is legitimate. Attributes on action parameters work too: [Range(1, 500)] int page.
The automatic 400
The built-in ModelStateInvalidFilter is an action filter with order -2000, so it runs before yours: a logging action filter never sees rejected requests. It returns application/problem+json with type, title, status, an errors dictionary and a traceId. A JSON syntax or type error is keyed by JSON path ($.quantity) and is usually joined by a second, noisy entry saying the whole parameter is required.
Error keys use C# property names (Quantity) while JSON errors use JSON names. Adding SystemTextJsonValidationMetadataProvider to MvcOptions.ModelMetadataDetailsProviders makes the keys match the JSON the client sent, which front ends need to highlight fields. It works from property metadata, so use classes or records with ordinary properties for request bodies; the parameters of a positional record keep their C# names.
To change the body, set InvalidModelStateResponseFactory in ConfigureApiBehaviorOptions and build it with ProblemDetailsFactory so traceId and your CustomizeProblemDetails hooks still apply. SuppressModelStateInvalidFilter = true turns the check off for the whole app; every action must then test ModelState.IsValid, and the one that forgets passes nulls to the database.
Over-posting
Binding an EF Core entity directly lets a client set any public property it can name: status, discount, customerId. Bind a request record that contains only what the client may change, then map it onto the entity.
Example
When to use it
- Return field-level errors keyed by the JSON names (shippingAddress.postcode) so a checkout form can highlight the wrong input.
- Require an Idempotency-Key header on POST /api/customers/{id}/orders and reject requests without it before the action runs.
- Switch validation failures to 422 problem details across all controllers with one InvalidModelStateResponseFactory.
More examples
Reshaping the automatic 400 and logging it
Some teams answer semantic validation failures with 422 and keep 400 for malformed requests; whichever you choose, keep one shape across the API. Building the body with <code>ProblemDetailsFactory</code> rather than <code>new ValidationProblemDetails(...)</code> keeps the <code>traceId</code> and any <code>CustomizeProblemDetails</code> extensions. Without <code>ContentTypes</code> the result would be sent as <code>application/json</code>, and clients that check for <code>application/problem+json</code> would miss it.
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Infrastructure;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddProblemDetails(options =>
options.CustomizeProblemDetails = context =>
context.ProblemDetails.Extensions["service"] = "orders-api");
builder.Services
.AddControllers()
.ConfigureApiBehaviorOptions(options =>
{
options.InvalidModelStateResponseFactory = context =>
{
var http = context.HttpContext;
var logger = http.RequestServices.GetRequiredService<ILoggerFactory>()
.CreateLogger("Orders.Validation");
logger.LogInformation("Rejected {Method} {Path}: {ErrorCount} invalid fields",
http.Request.Method, http.Request.Path, context.ModelState.ErrorCount);
// The factory adds traceId and runs the CustomizeProblemDetails hook above.
var problem = http.RequestServices.GetRequiredService<ProblemDetailsFactory>()
.CreateValidationProblemDetails(http, context.ModelState,
statusCode: StatusCodes.Status422UnprocessableEntity);
return new UnprocessableEntityObjectResult(problem)
{
ContentTypes = { "application/problem+json" }
};
};
});
var app = builder.Build();
app.MapControllers();
app.Run();
Cross-field rules, headers and query objects
<code>IValidatableObject.Validate</code> runs only after every property attribute has passed, so it can assume the dates are present; naming <code>To</code> as the member puts the message under that key for the client. Without <code>[FromQuery]</code>, <code>OrderSearch</code> would be inferred as the body of a GET, which most clients and proxies never send. Repeated keys (<code>ids=1001&ids=1002</code>) bind to arrays.
using System.ComponentModel.DataAnnotations;
using Microsoft.AspNetCore.Mvc;
public record DeliveryWindow(DateOnly From, DateOnly To) : IValidatableObject
{
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{
if (To < From)
yield return new ValidationResult("The window must end on or after its start.", [nameof(To)]);
if (To.DayNumber - From.DayNumber > 14)
yield return new ValidationResult("A delivery window can span at most 14 days.", [nameof(To)]);
}
}
public record OrderSearch
{
public string? Status { get; init; }
[Range(1, 1000)] public int Page { get; init; } = 1;
[Range(1, 100)] public int PageSize { get; init; } = 25;
public int[] Ids { get; init; } = [];
}
[ApiController]
[Route("api/orders")]
public sealed class OrderQueriesController : ControllerBase
{
// GET /api/orders?status=Paid&page=2&ids=1001&ids=1002
[HttpGet]
public IActionResult Search([FromQuery] OrderSearch search) => Ok(search);
// PUT /api/orders/1001/delivery-window with X-Tenant-Id: acme
[HttpPut("{orderId:int}/delivery-window")]
public IActionResult SetWindow(
int orderId,
DeliveryWindow window,
[FromHeader(Name = "X-Tenant-Id")][Required] string tenantId) =>
NoContent();
}
Over-posting: bind a request record, not the entity
Model binding sets every public settable property whose name appears in the JSON, so binding the entity hands the client your whole data model, including <code>CreditLimit</code> and <code>IsBlocked</code>. A request record also keeps validation rules for this operation separate from the database shape, and lets the entity change without changing the public contract. The route has no id: the customer comes from the caller's <code>customer_id</code> claim, so nobody can rewrite another customer's email by changing a number in the URL.
using System.Collections.Concurrent;
using System.ComponentModel.DataAnnotations;
using System.Security.Claims;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
public class Customer
{
public int Id { get; set; }
public string Name { get; set; } = "";
public string Email { get; set; } = "";
public decimal CreditLimit { get; set; }
public bool IsBlocked { get; set; }
}
// Only what a customer may change about themselves.
public record UpdateProfile(
[Required, StringLength(100)] string Name,
[Required, EmailAddress] string Email);
// In-memory stand-in for the database (a singleton); real code injects a scoped DbContext.
public sealed class CustomerStore
{
private readonly ConcurrentDictionary<int, Customer> _customers = new()
{
[42] = new Customer { Id = 42, Name = "Ada Lovelace", Email = "[email protected]", CreditLimit = 500m },
};
public Customer? Find(int id) => _customers.GetValueOrDefault(id);
}
[ApiController]
[Authorize]
[Route("api/customers/me")]
public sealed class CustomerProfileController(CustomerStore customers) : ControllerBase
{
// Wrong: public IActionResult Update(int id, Customer body) lets a client send
// {"creditLimit": 1000000, "isBlocked": false} and have it saved.
[HttpPut("profile")]
public IActionResult Update(UpdateProfile body)
{
// The id comes from the caller's token, never from the URL.
if (!int.TryParse(User.FindFirstValue("customer_id"), out var customerId)) return Forbid();
if (customers.Find(customerId) is not { } customer) return NotFound();
customer.Name = body.Name;
customer.Email = body.Email;
return NoContent();
}
}
Discussion