Model Binding and Validation in ASP.NET Core

Model binding transforms raw HTTP request data — query strings, form fields, route values, headers, and JSON bodies — into .NET objects that your action methods receive as parameters. Validation ensures those objects meet your business rules before your code processes them.

How Model Binding Works

When a request arrives at a controller action, ASP.NET Core examines each parameter and tries to populate it from the request data:

ProductsController.cs
[ApiController]
[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
    // id comes from the route, e.g. /api/products/42
    [HttpGet("{id}")]
    public IActionResult Get(int id)
    {
        return Ok(new { id });
    }

    // product comes from the JSON request body
    [HttpPost]
    public IActionResult Create([FromBody] CreateProductRequest product)
    {
        return CreatedAtAction(nameof(Get),
            new { id = product.Id }, product);
    }
}

The framework checks binding sources in this order: form values, route values, query strings. For [ApiController] attributed controllers, complex types default to [FromBody] and simple types default to route/query.

Binding Source Attributes

You can explicitly control where a parameter's value comes from:

Example.cs
[HttpGet("search")]
public IActionResult Search(
    [FromQuery] string term,           // ?term=widget
    [FromHeader] string authorization, // Authorization header
    [FromRoute] int categoryId)        // /api/products/search (if in route)
{
    return Ok(new { term, authorization, categoryId });
}

[HttpPost("upload")]
public IActionResult Upload(
    [FromForm] IFormFile file,         // multipart form data
    [FromForm] string description)
{
    return Ok(new { file.FileName, description });
}

In minimal APIs, binding source inference is even more automatic:

Example.cs
app.MapGet("/products/{id}", (int id, [FromQuery] string? fields) =>
    Results.Ok(new { id, fields }));

app.MapPost("/products", (CreateProductRequest body) =>
    Results.Created($"/products/{body.Id}", body));

Validation with Data Annotations

Decorate your model classes with validation attributes:

CreateProductRequest.cs
public class CreateProductRequest
{
    public int Id { get; set; }

    [Required]
    [StringLength(200, MinimumLength = 3)]
    public string Name { get; set; } = string.Empty;

    [Range(0.01, 99999.99)]
    public decimal Price { get; set; }

    [Required]
    [RegularExpression(@"^[A-Z]{2}-\d{4}$",
        ErrorMessage = "SKU must be in format XX-0000")]
    public string Sku { get; set; } = string.Empty;

    [Url]
    public string? ImageUrl { get; set; }
}

Controllers with [ApiController] automatically return a 400 Bad Request when ModelState is invalid — you do not need to check ModelState.IsValid manually.

The automatic response looks like this:

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

Custom Validation Attributes

When built-in attributes are not enough, create your own:

FutureDateAttribute.cs
public class FutureDateAttribute : ValidationAttribute
{
    protected override ValidationResult? IsValid(
        object? value, ValidationContext context)
    {
        if (value is DateTime date && date <= DateTime.UtcNow)
        {
            return new ValidationResult(
                "The date must be in the future.");
        }

        return ValidationResult.Success;
    }
}

public class CreateEventRequest
{
    [Required]
    public string Title { get; set; } = string.Empty;

    [FutureDate]
    public DateTime StartDate { get; set; }
}

IValidatableObject for Cross-Property Validation

When validation depends on multiple properties, implement IValidatableObject:

DateRangeRequest.cs
public class DateRangeRequest : IValidatableObject
{
    [Required]
    public DateTime StartDate { get; set; }

    [Required]
    public DateTime EndDate { get; set; }

    public IEnumerable<ValidationResult> Validate(
        ValidationContext context)
    {
        if (EndDate <= StartDate)
        {
            yield return new ValidationResult(
                "End date must be after start date.",
                new[] { nameof(EndDate) });
        }

        if ((EndDate - StartDate).TotalDays > 365)
        {
            yield return new ValidationResult(
                "Date range cannot exceed one year.",
                new[] { nameof(StartDate), nameof(EndDate) });
        }
    }
}

Validation in Minimal APIs

Minimal APIs do not perform automatic model validation. You need to handle it explicitly, either with an endpoint filter or manually:

Example.cs
app.MapPost("/events", (CreateEventRequest request) =>
{
    // Manual validation
    var context = new ValidationContext(request);
    var results = new List<ValidationResult>();

    if (!Validator.TryValidateObject(
        request, context, results, validateAllProperties: true))
    {
        return Results.ValidationProblem(
            results.ToDictionary(
                r => r.MemberNames.First(),
                r => r.ErrorMessage is not null
                    ? new[] { r.ErrorMessage }
                    : Array.Empty<string>()));
    }

    return Results.Created($"/events/{request.Title}", request);
});

Or use a reusable endpoint filter:

ValidationFilter.cs
public class ValidationFilter<T> : IEndpointFilter
{
    public async ValueTask<object?> InvokeAsync(
        EndpointFilterInvocationContext context,
        EndpointFilterDelegate next)
    {
        var argument = context.Arguments
            .OfType<T>().FirstOrDefault();

        if (argument is null)
            return Results.BadRequest("Request body is required.");

        var validationContext = new ValidationContext(argument);
        var results = new List<ValidationResult>();

        if (!Validator.TryValidateObject(
            argument, validationContext, results, true))
        {
            return Results.ValidationProblem(
                results.ToDictionary(
                    r => r.MemberNames.First(),
                    r => new[] { r.ErrorMessage ?? "Invalid value" }));
        }

        return await next(context);
    }
}

Key Takeaways

Model binding eliminates the tedium of parsing request data manually. Use binding source attributes when the default inference is not what you need. Validate input with data annotations for simple rules and IValidatableObject for cross-property logic. Remember that [ApiController] gives you automatic validation in MVC, but minimal APIs require explicit handling.