Skip to content

Model Binding & Validation

Key Points

  • Model binding maps incoming HTTP data (route, query, body, form, header) to method parameters or model properties.
  • Binding sources (in priority order, default): route → query → body. Override with [FromRoute], [FromQuery], [FromBody], [FromHeader], [FromForm], [FromServices].
  • [ApiController] infers binding sources, auto-validates, and returns 400 ProblemDetails on failure.
  • DataAnnotations validation: [Required], [StringLength], [Range], [RegularExpression], etc. Plus custom ValidationAttribute.
  • FluentValidation is the popular alternative for complex/composable validation rules.
  • Model state (ModelStateDictionary) collects errors during binding; controllers check ModelState.IsValid. With [ApiController], this is automatic.

Concepts (deep dive)

Binding sources

[ApiController]
[Route("api/[controller]")]
public class OrdersController : ControllerBase
{
    [HttpPost("{tenant}/{id:int}")]
    public IActionResult Create(
        string tenant,                       // [FromRoute]
        int id,                              // [FromRoute]
        string? sortBy,                      // [FromQuery]
        [FromHeader(Name = "X-Trace")] string? trace,
        [FromBody] CreateRequest req,         // ✅ explicit on complex
        [FromServices] IOrderRepo repo) { ... }
}

With [ApiController], sources are inferred: - Simple types → query (or route if matches a route token). - Complex types → body. - Types registered in DI → services. - A type marked [FromForm] → form data.

Custom binding sources via IBinderTypeProviderMetadata

Most apps don't need this; [FromHeader], [FromQuery], etc. cover common needs.

[FromBody] rules

  • Only one [FromBody] parameter per action (the body has a single content stream).
  • Defaults to application/json. For application/xml, register the XML formatter:
builder.Services.AddControllers().AddXmlSerializerFormatters();

Form data and file uploads

[HttpPost]
public async Task<IActionResult> Upload([FromForm] UploadDto dto)
{
    var path = Path.GetTempFileName();
    using var stream = File.OpenWrite(path);
    await dto.File.CopyToAsync(stream);
    return Ok(new { path });
}

public record UploadDto(string Title, IFormFile File);

For multi-file uploads, IFormFileCollection. For large uploads, stream from Request.Body directly to avoid double buffering.

DataAnnotations

public class CreateOrderRequest
{
    [Required, StringLength(100, MinimumLength = 1)]
    public string CustomerName { get; init; } = "";

    [Range(0.01, 1_000_000)]
    public decimal Total { get; init; }

    [EmailAddress]
    public string? Email { get; init; }

    [RegularExpression(@"^\d{10}$", ErrorMessage = "Phone must be 10 digits")]
    public string? Phone { get; init; }
}

The framework validates before the action runs. If any error, [ApiController] returns 400 with a ProblemDetails body listing each error.

Custom ValidationAttribute

public class FutureDateAttribute : ValidationAttribute
{
    protected override ValidationResult? IsValid(object? value, ValidationContext ctx)
    {
        if (value is DateTime d && d <= DateTime.UtcNow)
            return new ValidationResult($"{ctx.DisplayName} must be in the future");
        return ValidationResult.Success;
    }
}

public class Booking
{
    [FutureDate]
    public DateTime StartAt { get; init; }
}

Cross-property: implement IValidatableObject on the model:

public class Booking : IValidatableObject
{
    public DateTime StartAt { get; init; }
    public DateTime EndAt { get; init; }

    public IEnumerable<ValidationResult> Validate(ValidationContext ctx)
    {
        if (EndAt <= StartAt)
            yield return new ValidationResult("EndAt must be after StartAt", new[] { nameof(EndAt) });
    }
}

FluentValidation

public class CreateOrderRequestValidator : AbstractValidator<CreateOrderRequest>
{
    public CreateOrderRequestValidator()
    {
        RuleFor(r => r.CustomerName).NotEmpty().MaximumLength(100);
        RuleFor(r => r.Total).GreaterThan(0).LessThan(1_000_000);
        RuleFor(r => r.Email).EmailAddress().When(r => r.Email != null);
    }
}

builder.Services.AddValidatorsFromAssemblyContaining<CreateOrderRequestValidator>();

Manual invocation:

public async Task<IActionResult> Create(CreateOrderRequest req, IValidator<CreateOrderRequest> v)
{
    var result = await v.ValidateAsync(req);
    if (!result.IsValid) return ValidationProblem(result.ToDictionary());
    /* ... */
}

Or with the integrated middleware (FluentValidation.AspNetCore package — note: deprecated for new code, use manual injection or endpoint filters).

For Minimal APIs:

app.MapPost("/orders", async Task<Results<Ok, ValidationProblem>> (
    CreateOrderRequest req,
    IValidator<CreateOrderRequest> v) =>
{
    var r = await v.ValidateAsync(req);
    return r.IsValid ? TypedResults.Ok() : TypedResults.ValidationProblem(r.ToDictionary());
});

Validation in Minimal APIs (.NET 9+ built-in)

.NET 9 added some built-in validation hooks for Minimal APIs (still maturing). For production, FluentValidation is the most-used choice in 2026.

ProblemDetails for validation errors

[ApiController] returns:

{
  "type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
  "title": "One or more validation errors occurred.",
  "status": 400,
  "errors": {
    "CustomerName": ["The CustomerName field is required."],
    "Total": ["The field Total must be between 0.01 and 1000000."]
  }
}

Standard shape; clients can render errors per-field.

Custom model binders

For non-JSON-shaped input (e.g., a comma-separated list in a query parameter):

public class CsvIntsBinder : IModelBinder
{
    public Task BindModelAsync(ModelBindingContext ctx)
    {
        var raw = ctx.ValueProvider.GetValue(ctx.ModelName).FirstValue;
        var values = raw?.Split(',').Select(int.Parse).ToArray();
        ctx.Result = ModelBindingResult.Success(values ?? Array.Empty<int>());
        return Task.CompletedTask;
    }
}

public IActionResult Get([ModelBinder(typeof(CsvIntsBinder))] int[] ids) { ... }

Use sparingly; usually the standard binders cover the case.


Code: correct vs wrong

❌ Wrong: complex query parameter without [FromQuery]

[HttpGet]
public IActionResult Search(SearchQuery q) { ... }
// In MVC controllers without [ApiController], may try to bind from form

✅ Correct: explicit

[HttpGet]
public IActionResult Search([FromQuery] SearchQuery q) { ... }

❌ Wrong: validating with manual if (!ModelState.IsValid) after [ApiController]

[ApiController]
public class C : ControllerBase
{
    [HttpPost]
    public IActionResult Create(R req)
    {
        if (!ModelState.IsValid) return BadRequest(ModelState);   // never reached
    }
}

✅ Correct: trust [ApiController] auto-400

[ApiController]
public class C : ControllerBase
{
    [HttpPost] public IActionResult Create(R req) { /* req is validated */ }
}

❌ Wrong: large upload buffered into memory

[HttpPost]
public IActionResult Upload(IFormFile file)
{
    using var ms = new MemoryStream();
    file.CopyTo(ms);                    // ❌ entire file in memory
    Process(ms.ToArray());
}

✅ Correct: stream

[HttpPost]
[DisableRequestSizeLimit]
public async Task<IActionResult> Upload()
{
    using var dst = File.OpenWrite(path);
    await Request.Body.CopyToAsync(dst);   // streamed
    return Ok();
}

Design patterns for this topic

Pattern 1 — "DTO per use case"

  • Intent: define a request type per endpoint; only bindable fields.

Pattern 2 — "FluentValidation for complex/composable rules"

  • Intent: rule chains, conditional rules.

Pattern 3 — "DataAnnotations for simple per-property rules"

  • Intent: declarative; minimal overhead.

Pattern 4 — "Custom ValidationAttribute for domain rules"

  • Intent: reusable rule (e.g., [FutureDate]).

Pattern 5 — "Stream uploads for large files"

  • Intent: avoid double buffering.

Pros & cons / trade-offs

Approach Pros Cons
DataAnnotations Built-in; simple Limited expressiveness
IValidatableObject Cross-property Method-based; less declarative
FluentValidation Composable; testable External dep
Custom binders Total control More code

When to use / when to avoid

  • Use DTOs for every public endpoint.
  • Use DataAnnotations for simple rules.
  • Use FluentValidation for complex/conditional rules.
  • Avoid binding entities directly to actions.
  • Avoid manual ModelState.IsValid checks with [ApiController].

Interview Q&A

Q1. What's the default binding source for a complex type with [ApiController]? Body ([FromBody]).

Q2. How does validation differ with vs without [ApiController]? With: framework auto-returns 400 + ProblemDetails on invalid model. Without: you must check ModelState.IsValid and return manually.

Q3. When would you use IValidatableObject over attributes? When validation depends on multiple properties (e.g., EndAt after StartAt).

Q4. How does FluentValidation differ from DataAnnotations? FV is method-based, composable, supports conditional rules and complex chains. DA is attribute-based, declarative, simpler.

Q5. How do you handle large file uploads? Stream from Request.Body. Set [DisableRequestSizeLimit] if appropriate. Increase Kestrel's MaxRequestBodySize for endpoint-specific limits.

Q6. What's a IFormFile? Abstraction over a multipart-uploaded file. Read via OpenReadStream or CopyToAsync. Buffered to disk by ASP.NET Core if larger than threshold.

Q7. Why might a [FromBody] parameter come back null? Empty body (Content-Length = 0), wrong Content-Type, malformed JSON, or no body sent. With [ApiController], the framework returns 400 instead of null.

Q8. How do you validate a query collection? Use [FromQuery] and apply DataAnnotations on the type's properties, or FluentValidation.

Q9. Custom binder vs custom converter? Binder maps the entire input → object. Converter (e.g., JsonConverter<T>) maps a JSON token → field value. Different layers.

Q10. How do you skip validation for a property? [ValidateNever] attribute, or exclude the property from the DTO entirely (better).


Gotchas / common mistakes

  • ⚠️ Two [FromBody] parameters — only one allowed.
  • ⚠️ Complex query without [FromQuery] in non-[ApiController] MVC — binds from form.
  • ⚠️ Large upload buffered into memoryOOM under load.
  • ⚠️ Forgetting [Required] on non-nullable strings — they bind as empty.
  • ⚠️ Validation logic in controller — should be in DTO/validator.
  • ⚠️ ModelState reset across requests — it's per-request; can't accumulate across calls.

Further reading