Just added: Algorithms you can run and practice
Updated Jul 10, 2026 Edit on GitHub

Model Binding & Validation

Here's the mental model: a raw HTTP request is just bytes - a URL, some headers, maybe a blob of JSON. Model binding turns those bytes into typed C# parameters. Validation then checks those values are actually sane before your real logic runs. Two steps, in order: shape the data, then trust the data.

Phase 2 had handlers take parameters and ASP.NET Core somehow filled them in - this phase is the "somehow." We'll keep growing the products API and look plainly at a sharp edge that trips up nearly everyone moving from controllers to minimal APIs.

Where does each parameter come from?

For each parameter in a minimal API handler, ASP.NET Core decides which part of the request fills it using a small set of inference rules:

  • A parameter whose name matches a route placeholder binds from the route.
  • A simple type (string, int, Guid, etc.) that isn't in the route binds from the query string.
  • A complex type (your own class or record) binds from the JSON body.
  • Known framework types (like a CancellationToken or a registered service) are supplied by the framework.

Most of the time you don't annotate anything - inference just works.

// GET /products/42?fields=name
app.MapGet("/products/{id:int}", (int id, string? fields) =>
{
    // id  ← from the route ("{id:int}")
    // fields ← from the query string ("?fields=name")
    return Results.Ok(new { id, fields });
});

What just happened: id matched the route placeholder, so it bound from the path. fields is a simple type with no matching route segment, so ASP.NET Core looked in the query string. Nothing was annotated - names and types told the framework everything.

When you need to be explicit

Inference is a default, not a law. To override it, or just make the source obvious to the next reader, reach for the [From*] attributes:

Attribute Binds from
[FromBody] the request body (JSON)
[FromRoute] a route placeholder
[FromQuery] the query string
[FromHeader] a request header
[FromServices] the dependency-injection container (more on this in Dependency Injection)

💡 You rarely need [FromBody] for a complex type - it's already inferred. But you can only have one body-bound parameter per handler (a request has one body), and that's a common source of "why is this null?" confusion when you accidentally mark two parameters to read the body.

Binding the body to a record

The bread-and-butter case: a POST that creates a product. The client sends JSON, landed in a CreateProduct record.

app.MapPost("/products", (CreateProduct input) =>
{
    var id = Guid.NewGuid();
    var product = new Product(id, input.Name, input.Price);
    // (save it somewhere - that's Phase 6's job)
    return Results.Created($"/products/{id}", product);
});

public record CreateProduct(string Name, decimal Price);

What just happened: CreateProduct is a complex type, so ASP.NET Core deserialized the JSON body into it - matching Name and Price by property name (case-insensitive by default). POST { "name": "Keyboard", "price": 49.99 } and input arrives fully populated. You return 201 Created with a Location header.

📝 If the JSON is malformed, binding fails before your handler runs and the client gets a 400. But if it's well-formed yet nonsense for your domain - an empty name, a negative price - binding happily succeeds. The bytes parsed fine; they're just bad data. Catching that is validation's job.

Validation with data annotations

ASP.NET Core's built-in validation is DataAnnotations: attributes on the properties of your bound type that declare the rules. The common ones:

using System.ComponentModel.DataAnnotations;

public class CreateProduct
{
    [Required]
    [StringLength(120, MinimumLength = 1)]
    public string Name { get; set; } = "";

    [Range(0, 100000)]
    public decimal Price { get; set; }
}

What just happened: we declared, right next to the data, what "valid" means - Name must be present and at most 120 characters, Price must sit between 0 and 100,000. (We switched from a record to a class with settable properties; a mutable class is the more common shape for a validated input model.) These attributes are pure declarations - they don't do anything on their own, and that's the part that surprises people.

⚠️ The plain minimal-API gotcha

Here's the thing nobody warns you about until it bites: minimal APIs do not automatically run DataAnnotations validation. Decorate every property with [Required] and [Range] you like - a minimal API handler will run anyway, with an empty name and a price of -5, because nothing in the default pipeline ever checked.

This catches experienced ASP.NET developers especially hard, because in MVC controllers it does happen automatically (more below). In a minimal API, the rules are documentation until you wire up an enforcer. Three straightforward options:

  1. Validate manually in the handler.
  2. Add an endpoint filter that validates every request to that endpoint.
  3. Use a library - MinimalApis.Extensions / MiniValidation (a tiny helper that runs DataAnnotations for you) or FluentValidation (rules in separate validator classes, popular on bigger teams).

Let's do option 1 so you can see the machinery - then you'll appreciate why the others exist.

using System.ComponentModel.DataAnnotations;

app.MapPost("/products", (CreateProduct input) =>
{
    var context = new ValidationContext(input);
    var results = new List<ValidationResult>();

    if (!Validator.TryValidateObject(input, context, results, validateAllProperties: true))
    {
        // turn failures into the shape ValidationProblem wants:
        // { "Name": ["The Name field is required."], ... }
        var errors = results
            .SelectMany(r => r.MemberNames.Select(name => (name, r.ErrorMessage)))
            .GroupBy(x => x.name, x => x.ErrorMessage ?? "Invalid")
            .ToDictionary(g => g.Key, g => g.ToArray());

        return Results.ValidationProblem(errors);
    }

    var product = new Product(Guid.NewGuid(), input.Name, input.Price);
    return Results.Created($"/products/{product.Id}", product);
});

What just happened: Validator.TryValidateObject is the engine that reads the annotations and runs them (validateAllProperties: true checks every property instead of stopping at the first). On failure we reshape the results into a dictionary of field → messages and hand it to Results.ValidationProblem, which returns a 400 with a standard ProblemDetails body. On success we proceed to create the product - validation runs before the create logic, that ordering is the whole point.

💡 In real projects you'd lift that block into an endpoint filter or let MiniValidation do the TryValidateObject dance for you. The manual version above is here so the magic isn't magic.

Why some teams still reach for controllers

📝 If validation being automatic sounds appealing, you're not alone - that's one real reason teams pick MVC controllers over minimal APIs. A controller marked [ApiController] validates the bound model for you: it runs the DataAnnotations, populates ModelState, and short-circuits with a 400 and a ProblemDetails body before your action method ever runs.

[ApiController]
[Route("products")]
public class ProductsController : ControllerBase
{
    [HttpPost]
    public IActionResult Create(CreateProduct input)
    {
        // If we got here, input is already valid.
        // [ApiController] auto-returned 400 otherwise - ModelState was checked for us.
        var product = new Product(Guid.NewGuid(), input.Name, input.Price);
        return Created($"/products/{product.Id}", product);
    }
}

What just happened: [ApiController] opted this class into a bundle of conventions, one being automatic model validation. By the time Create runs, the framework has already inspected ModelState; if Name was empty it never called your method. You write less plumbing; you give up some of the explicitness of minimal APIs. Neither choice is wrong - it's a trade.

Pick whichever fits the project. This guide stays on minimal APIs and wires validation in deliberately - it keeps the "request flows in, gets shaped, gets checked" model visible instead of hidden behind a convention.

Recap

  • Binding turns the request into typed C# parameters; validation checks those values - always in that order, before your logic runs.
  • In minimal APIs the source is inferred: route placeholders by name, simple types from the query string, a complex type from the JSON body. Override with [FromBody], [FromRoute], [FromQuery], [FromHeader], [FromServices].
  • DataAnnotations ([Required], [StringLength], [Range], [EmailAddress]) declare validity right on the model - but they're inert until something runs them.
  • ⚠️ Minimal APIs do NOT auto-validate. Validate manually, add an endpoint filter, or use MiniValidation / FluentValidation. On failure return Results.ValidationProblem(errors) for a standard 400.
  • 💡 [ApiController] controllers DO auto-validate via ModelState and return 400 automatically - a real reason some teams still choose controllers.

Quick check

[
  {
    "q": "In a minimal API, where does a complex type (like CreateProduct) bind from by default?",
    "choices": ["The route values", "The query string", "The JSON request body", "Request headers"],
    "answer": 2,
    "explain": "Inference binds simple types from the query and route, and a complex type from the JSON body. Override with [From*] attributes if needed."
  },
  {
    "q": "You decorated CreateProduct with [Required] and [Range], but your minimal API handler still runs with bad data. Why?",
    "choices": ["The attributes are spelled wrong", "Minimal APIs don't run DataAnnotations validation automatically", "You forgot [FromBody]", "DataAnnotations only work on GET requests"],
    "answer": 1,
    "explain": "Minimal APIs don't auto-validate. You must validate manually, add an endpoint filter, or use a library like MiniValidation or FluentValidation."
  },
  {
    "q": "What does a controller marked with [ApiController] do that a plain minimal API does not?",
    "choices": ["Binds the JSON body", "Automatically validates the model and returns 400 before your method runs", "Generates routes from the method name", "Runs faster"],
    "answer": 1,
    "explain": "[ApiController] auto-runs DataAnnotations, populates ModelState, and short-circuits with a 400 + ProblemDetails when validation fails - before your action executes."
  }
]

Before the quiz: without looking back, say (or jot down) the core idea of this phase in your own words.

Check your understanding 3 questions

1. In a minimal API, where does a complex type (like CreateProduct) bind from by default?

2. You decorated CreateProduct with [Required] and [Range], but your minimal API handler still runs with bad data. Why?

3. What does a controller marked with [ApiController] do that a plain minimal API does not?