Model Binding Basics — Forms, Route Values and Query Strings

Model binding is ASP.NET Core’s mechanism for automatically mapping HTTP request data (route values, query strings, form fields, JSON bodies) to action method parameters. Without model binding, you would call Request.Form["title"], Request.RouteValues["id"], and manually parse and convert every value. Model binding does this automatically, including type conversion, complex object population, and validation. Understanding the default binding behaviour and how to be explicit with binding source attributes makes action methods clean and self-documenting.

Model Binding Sources and Order

// ── Default binding — framework tries sources in order ────────────────────
// For simple types (int, string, bool): Route → QueryString → Form
// For complex types: Form → Body (JSON)

// Route value binding (from URL template variables)
// URL: GET /posts/details/42
// Route template: {controller}/{action}/{id?}
public IActionResult Details(int id)  // id = 42 from route value
{
    return View();
}

// Query string binding (from URL after ?)
// URL: GET /posts?page=2&search=dotnet
public IActionResult Index(int page = 1, string search = "")  // bound from query string
{
    return View();
}

// Complex object binding from form POST
// POST /posts/create with form fields: Title=Hello&Body=World&Tags=csharp
public IActionResult Create(CreatePostViewModel model)  // fully bound from form
{
    if (!ModelState.IsValid) return View(model);
    return RedirectToAction(nameof(Index));
}

// ── Explicit binding source attributes ───────────────────────────────────
// Use these to be explicit about where a parameter comes from
public IActionResult Details(
    [FromRoute]  int    id,          // must come from route
    [FromQuery]  string tab = "body", // must come from query string
    [FromForm]   string notes = "")   // must come from form data
{
    return View();
}

// [FromBody] — for JSON payloads (more common in Web API than MVC)
public IActionResult CreateJson([FromBody] CreatePostRequest request)
{
    return Json(new { success = true });
}
Note: Model binding for complex types from form fields uses the property name as the field name by convention. A form field named Title binds to CreatePostViewModel.Title. For nested objects, the convention uses dot notation: a form field named Address.City binds to Model.Address.City. For collections: Tags[0]=csharp&Tags[1]=dotnet binds to List<string> Tags. Tag Helpers in Razor views (asp-for="Tags[0]") automatically generate the correct field names for collections.
Tip: Use [FromQuery] explicitly on action parameters that should only come from query strings, especially when the parameter name matches a route template variable. Without explicit binding sources, the framework tries route values first — a query string parameter with the same name as a route token might be shadowed by the route value. Explicit binding sources prevent ambiguity and make the action method self-documenting about its expected input contract.
Warning: Model binding populates ALL properties of a complex type from the request by default. If you bind a full domain entity (rather than a dedicated ViewModel), a malicious user can POST any property of that entity — including ones not shown in the form, like IsAdmin, IsActive, or CreatedById. This is the mass assignment / over-posting vulnerability. Always use a dedicated ViewModel with only the properties the form needs, and map it to the domain entity in the service layer.

ModelState Validation

// ── DataAnnotations on ViewModel ─────────────────────────────────────────
public class CreatePostViewModel
{
    [Required(ErrorMessage = "Title is required.")]
    [StringLength(200, MinimumLength = 5)]
    public string Title { get; set; } = string.Empty;

    [Required(ErrorMessage = "Body content is required.")]
    public string Body  { get; set; } = string.Empty;

    [StringLength(100)]
    [RegularExpression(@"^[a-z0-9-]+$", ErrorMessage = "Slug must be lowercase with hyphens.")]
    public string Slug  { get; set; } = string.Empty;
}

// ── Checking ModelState in the action ────────────────────────────────────
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> Create(CreatePostViewModel model)
{
    // ModelState.IsValid = true means all DataAnnotations passed
    if (!ModelState.IsValid)
    {
        // Re-display the form — validation errors shown by Tag Helpers
        return View(model);
    }

    // ModelState errors can be added manually
    var slugExists = await _service.SlugExistsAsync(model.Slug);
    if (slugExists)
    {
        ModelState.AddModelError(nameof(model.Slug), "This slug is already in use.");
        return View(model);
    }

    await _service.CreateAsync(model);
    return RedirectToAction(nameof(Index));
}

Common Mistakes

Mistake 1 — Binding a domain entity directly (mass assignment / over-posting)

❌ Wrong — user can set any entity property including IsAdmin:

public IActionResult Create(User user)  // binds ALL User properties — dangerous!

✅ Correct — use a ViewModel with only the needed properties.

Mistake 2 — Not checking ModelState before using the model

❌ Wrong — using model data when validation failed; null reference or constraint violation:

public IActionResult Create(CreatePostViewModel m) { await _svc.Create(m); }

✅ Correct — always check if (!ModelState.IsValid) return View(model); first.

🧠 Test Yourself

A form has fields Title and Body. A malicious user adds a hidden field IsAdmin=true to the POST. The action binds a UserViewModel that has IsAdmin. Is this a risk?