Model Binding & Validation
How [FromBody], [FromQuery], and [FromRoute] pull data from the HTTP request into your C# objects.
Model binding is ASP.NET Core's mechanism for transforming raw HTTP request data β route parameters, query strings, headers, and body β into strongly-typed C# objects. It's automatic, extensible, and deeply integrated with validation. Understanding the binding source hierarchy and how [ApiController] changes the defaults is essential for building APIs that handle invalid input correctly.
ASP.NET Core looks at parameter attributes to determine where to read from: [FromBody] reads the request body (JSON/XML via input formatters), [FromQuery] reads query string values, [FromRoute] reads matched route parameters, [FromHeader] reads HTTP headers, [FromForm] reads form data.
When [ApiController] is present, binding sources are inferred: complex types β [FromBody], simple types that appear in route template β [FromRoute], everything else β [FromQuery]. This inference can surprise you when you add a new route parameter that clashes with an existing query string parameter.
The Content-Type header determines which input formatter is used. 'application/json' β System.Text.Json (default) or Newtonsoft.Json. 'application/xml' β XML formatter (must be added explicitly). If no formatter handles the Content-Type, the request is rejected with 415 Unsupported Media Type.
After binding, ASP.NET Core runs DataAnnotations validation on all bound models. [Required], [Range], [StringLength], [EmailAddress], [RegularExpression] etc. Results are stored in ModelState. With [ApiController], invalid ModelState automatically returns a 400 ProblemDetails response before your action executes.
Without [ApiController], you must check ModelState.IsValid yourself. The automatic 400 behavior of [ApiController] calls the InvalidModelStateResponseFactory delegate, which you can override globally to customize the error format β useful for APIs that need a specific error schema.
Key Concepts
Reads the entire request body using an input formatter. Can only be used ONCE per action β the body stream can't be rewound. Complex type default with [ApiController].
Reads from the URL query string: ?name=value. Works for simple types and collections (repeated keys). Complex types need a custom model binder.
Reads from matched route parameters. Automatically inferred for parameters that appear in the route template when [ApiController] is present.
Reads a specific HTTP header. Header names are case-insensitive in HTTP but you specify them in the Name property: [FromHeader(Name = "X-Api-Key")].
Dictionary of field-level validation results. ModelState.IsValid is false if any bound value failed validation. ModelState["Email"].Errors contains the failure messages.
Interface for cross-property validation logic that can't be expressed with a single attribute. Validate() is called after all attribute validation passes.
1// The model β DataAnnotations drive both binding and validation2public class CreateOrderRequest3{4 [Required]5 [StringLength(100, MinimumLength = 1)]6 public string ProductName { get; set; } = string.Empty;78 [Required]9 [Range(1, 10000)]10 public int Quantity { get; set; }1112 [Required]13 [EmailAddress]14 public string CustomerEmail { get; set; } = string.Empty;1516 [FromHeader(Name = "X-Idempotency-Key")]17 public string? IdempotencyKey { get; set; }18}1920[ApiController]21[Route("api/orders")]22public class OrdersController : ControllerBase23{24 private readonly IOrderService _orderService;2526 public OrdersController(IOrderService orderService)27 => _orderService = orderService;2829 // [ApiController] auto-returns 400 if ModelState is invalid30 // No need for: if (!ModelState.IsValid) return BadRequest(...)31 [HttpPost]32 public async Task<IActionResult> Create(33 [FromBody] CreateOrderRequest request, // JSON body34 [FromQuery] string? source, // ?source=web35 [FromRoute] string? tenantId, // route param36 CancellationToken cancellationToken)37 {38 var order = await _orderService.CreateAsync(request, source, cancellationToken);39 return CreatedAtAction(nameof(GetById), new { id = order.Id }, order);40 }4142 // Custom model binder for complex query string object43 [HttpGet("search")]44 public IActionResult Search(45 [FromQuery][ModelBinder(typeof(OrderSearchBinder))] OrderSearchFilter filter)46 {47 // filter is fully populated from ?minPrice=10&maxPrice=500&status=active48 return Ok();49 }50}5152// IValidatableObject for cross-property validation53public class DateRangeRequest : IValidatableObject54{55 public DateTimeOffset From { get; set; }56 public DateTimeOffset To { get; set; }5758 public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)59 {60 if (To <= From)61 yield return new ValidationResult(62 "To must be after From",63 new[] { nameof(To) });64 }65}
Model binding is where user input enters your application. Getting it wrong means null reference exceptions, validation bypasses, and silent data corruption. The interaction between [ApiController], ModelState, and [FromBody] contains several non-obvious behaviors that only manifest under specific conditions β the kind that make it to production.
Common Pitfalls
1Body Stream Already Read β Silent Empty Model
You have a middleware that reads the request body to log audit trails. After deployment, all POST endpoints start receiving empty/null models. No errors, no exceptions β just null data being saved to the database.
[FromBody] reads from the request body stream, which is forward-only by default. Your logging middleware called await new StreamReader(context.Request.Body).ReadToEndAsync() which advanced the stream position to the end. When the model binder ran later, it read an empty stream and silently bound an empty model.
Enable request body buffering BEFORE any middleware that reads it: app.Use(async (ctx, next) => { ctx.Request.EnableBuffering(); await next(); }). EnableBuffering() wraps the stream in a FileBufferingReadStream that supports seeking. Also set context.Request.Body.Position = 0 after reading in your middleware.
Takeaway: The request body is a stream, not a string. Middleware that reads it MUST call EnableBuffering() and reset the position afterward. This is one of those bugs that's invisible β the model binds as empty without throwing, and data gets corrupted silently.
2Missing [ApiController] Means No Auto-Validation
You have a legacy controller inheriting from Controller (not ControllerBase) without [ApiController]. You add DataAnnotations to your model expecting automatic 400 responses for invalid input. Invalid data reaches your service layer and causes a NullReferenceException.
[ApiController] is what enables automatic ModelState validation. Without it, ASP.NET Core populates ModelState but NEVER checks it automatically β your action receives invalid data as if it were valid. The [Required] on an email field means nothing if you don't check ModelState.IsValid.
Add [ApiController] to all API controllers. For legacy controllers you can't annotate, add a global action filter: builder.Services.AddControllers(options => options.Filters.Add<ValidateModelStateFilter>()). Implement ValidateModelStateFilter to check ModelState.IsValid and return 400 for all actions.
Takeaway: DataAnnotations validation requires [ApiController] for automatic enforcement. Adding [Required] to your model creates a false sense of security if you don't verify it's actually being enforced. Always test that invalid requests actually return 400, not just that valid requests return 200.
3DateTimeOffset Parsing Fails on Non-English Servers
Your API accepts a date parameter via [FromQuery]. Works perfectly on your machine and CI (en-US). Deployed to a German server (de-DE), and all date queries return 400 Bad Request β even with valid dates like '2024-01-15'.
Model binding uses the current thread's CultureInfo for parsing. The de-DE culture uses '.' as a decimal separator and expects DD.MM.YYYY date format. '2024-01-15' doesn't match the German date pattern, so binding fails with a parse error and ModelState marks the field as invalid.
Use ISO 8601 format (yyyy-MM-dd) in combination with DateTimeOffset binding that explicitly specifies invariant culture. Register a custom ModelBinderProvider, or use a string parameter and parse manually with DateTimeOffset.Parse(value, CultureInfo.InvariantCulture). For [FromBody] JSON, System.Text.Json handles ISO 8601 correctly regardless of culture.
Takeaway: Model binding is culture-sensitive for [FromQuery] and [FromRoute] by default. Any server not running en-US culture will fail to parse dates that work on your dev machine. Either use ISO 8601 with explicit invariant culture parsing, or switch to POST bodies where System.Text.Json handles culture-invariant parsing automatically.