AI WisdomArchitecture & guides β†—
HT
How Things Work

Model Binding & Validation

How [FromBody], [FromQuery], and [FromRoute] pull data from the HTTP request into your C# objects.

How It Works

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.

1
Binding Source Selection

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.

2
[ApiController] Inference Rules

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.

3
Input Formatters for [FromBody]

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.

4
DataAnnotations Validation

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.

5
ModelState and Manual Checking

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

πŸ“¦[FromBody]

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].

❓[FromQuery]

Reads from the URL query string: ?name=value. Works for simple types and collections (repeated keys). Complex types need a custom model binder.

πŸ›€οΈ[FromRoute]

Reads from matched route parameters. Automatically inferred for parameters that appear in the route template when [ApiController] is present.

πŸ“‹[FromHeader]

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")].

βœ…ModelState

Dictionary of field-level validation results. ModelState.IsValid is false if any bound value failed validation. ModelState["Email"].Errors contains the failure messages.

πŸ”—IValidatableObject

Interface for cross-property validation logic that can't be expressed with a single attribute. Validate() is called after all attribute validation passes.

Model Binding Sources & DataAnnotations Validation
tsx
1// The model β€” DataAnnotations drive both binding and validation
2public class CreateOrderRequest
3{
4 [Required]
5 [StringLength(100, MinimumLength = 1)]
6 public string ProductName { get; set; } = string.Empty;
7
8 [Required]
9 [Range(1, 10000)]
10 public int Quantity { get; set; }
11
12 [Required]
13 [EmailAddress]
14 public string CustomerEmail { get; set; } = string.Empty;
15
16 [FromHeader(Name = "X-Idempotency-Key")]
17 public string? IdempotencyKey { get; set; }
18}
19
20[ApiController]
21[Route("api/orders")]
22public class OrdersController : ControllerBase
23{
24 private readonly IOrderService _orderService;
25
26 public OrdersController(IOrderService orderService)
27 => _orderService = orderService;
28
29 // [ApiController] auto-returns 400 if ModelState is invalid
30 // No need for: if (!ModelState.IsValid) return BadRequest(...)
31 [HttpPost]
32 public async Task<IActionResult> Create(
33 [FromBody] CreateOrderRequest request, // JSON body
34 [FromQuery] string? source, // ?source=web
35 [FromRoute] string? tenantId, // route param
36 CancellationToken cancellationToken)
37 {
38 var order = await _orderService.CreateAsync(request, source, cancellationToken);
39 return CreatedAtAction(nameof(GetById), new { id = order.Id }, order);
40 }
41
42 // Custom model binder for complex query string object
43 [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=active
48 return Ok();
49 }
50}
51
52// IValidatableObject for cross-property validation
53public class DateRangeRequest : IValidatableObject
54{
55 public DateTimeOffset From { get; set; }
56 public DateTimeOffset To { get; set; }
57
58 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}
πŸ’‘
Why This Matters

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

⚠[FromBody] reads the request stream once. If any middleware or custom code reads the body before the model binder, the binder gets an empty stream and silently binds null/default values. Always call request.EnableBuffering() before reading the body in middleware.
⚠Missing [ApiController] means ModelState.IsValid is NEVER automatically checked. Your DataAnnotations attributes still populate ModelState, but invalid data reaches your action as if nothing was wrong.
⚠Complex types from query string don't bind automatically. ?filter.minPrice=10&filter.maxPrice=500 requires either [FromQuery] prefix attribute or a custom IModelBinder β€” it doesn't just work.
⚠DateTimeOffset and DateTime query string binding is culture-sensitive. A date that parses correctly on en-US will fail on de-DE or tr-TR server cultures. Always parse dates with CultureInfo.InvariantCulture explicitly.
⚠The [ApiController] binding source inference can bite you: if you add a route parameter with the same name as an existing [FromQuery] parameter, [ApiController] silently switches it to [FromRoute], changing behavior without any warning.
Real-World Use Cases

1Body Stream Already Read β€” Silent Empty Model

Scenario

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.

Problem

[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.

Solution

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

Scenario

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.

Problem

[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.

Solution

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

Scenario

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'.

Problem

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.

Solution

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.