Advanced Model Binding — Sources, Custom Binders and Complex Types

Model binding is how ASP.NET Core takes HTTP request data — from the URL path, query string, headers, form fields, or JSON body — and populates action method parameters. With [ApiController], binding source inference handles most cases automatically. But some scenarios require explicit binding source attributes: combining body and route data, reading from headers, accepting file uploads, or creating a custom binder for domain-specific types.

Binding Sources

// ── Binding source attributes ─────────────────────────────────────────────
[HttpPut("{id:int}")]
public async Task<IActionResult> Update(
    [FromRoute]  int               id,         // from /api/posts/42
    [FromBody]   UpdatePostRequest request,    // from JSON body
    [FromHeader(Name = "X-Idempotency-Key")] string? idempotencyKey,  // from header
    CancellationToken ct = default)
{
    // id = route value, request = JSON body, idempotencyKey = header
}

// ── Binding from query string — complex object ────────────────────────────
[HttpGet]
public async Task<IActionResult> Search(
    [FromQuery] PostSearchQuery query,   // all query string params bound to object
    CancellationToken ct = default)
{
    // ?page=2&size=10&q=dotnet&category=tech
    // query.Page = 2, query.Size = 10, query.SearchText = "dotnet", etc.
}

// ── File upload — binding from form ──────────────────────────────────────
[HttpPost("cover-image")]
public async Task<IActionResult> UploadCover(
    [FromRoute] int       id,
    [FromForm]  IFormFile file,          // multipart/form-data
    [FromForm]  string?   altText = null,
    CancellationToken ct = default)
{
    if (file.Length == 0) return BadRequest("File is empty.");
    if (file.Length > 5 * 1024 * 1024) return BadRequest("File exceeds 5MB limit.");

    var extension = Path.GetExtension(file.FileName).ToLowerInvariant();
    if (!new[] { ".jpg", ".jpeg", ".png", ".webp" }.Contains(extension))
        return BadRequest("Only JPG, PNG, and WebP images are accepted.");

    await _service.UploadCoverAsync(id, file, altText, ct);
    return NoContent();
}

// ── [FromServices] — inject a service as an action parameter ─────────────
// Alternative to constructor injection for one-off dependencies
[HttpGet("{id:int}/export")]
public async Task<IActionResult> Export(
    int id,
    [FromServices] IExportService exportService,  // resolved from DI
    CancellationToken ct = default)
{
    var bytes = await exportService.ExportPostAsPdfAsync(id, ct);
    return File(bytes, "application/pdf", $"post-{id}.pdf");
}
Note: With [ApiController], binding source inference follows these rules: complex types are inferred as [FromBody] (JSON), IFormFile and IFormFileCollection are inferred as [FromForm], and simple types (int, string, etc.) that match a route template token are inferred as [FromRoute]; otherwise as [FromQuery]. These inferences work for 95% of cases. Use explicit binding source attributes when you need to override inference — reading a complex type from the query string, or a simple type from the body.
Tip: Binding a complex search/filter object from the query string with [FromQuery] PostSearchQuery query is cleaner than declaring individual [FromQuery] string? q, [FromQuery] int page, etc. parameters for every filter. The query string object is created by model binding from the individual query parameters and can have DataAnnotations validation. This pattern scales well — adding a new filter parameter only requires adding a property to the query object, not changing the action signature.
Warning: File uploads require the form to use multipart/form-data encoding. If the Angular client sends a file with application/json, the IFormFile parameter will be null. Verify the Content-Type in Swagger (should show multipart/form-data for file upload endpoints). Also configure Kestrel’s request size limit to allow larger files: builder.WebHost.ConfigureKestrel(opts => opts.Limits.MaxRequestBodySize = 50 * 1024 * 1024) for a 50MB limit.

Custom Model Binder

// ── Custom binder for comma-separated integer lists ────────────────────────
// Usage: GET /api/posts?ids=1,2,3,4,5
// Default binding would expect: ?ids=1&ids=2&ids=3 (separate params)

public class CommaDelimitedIntsBinder : IModelBinder
{
    public Task BindModelAsync(ModelBindingContext bindingContext)
    {
        var value = bindingContext.ValueProvider
            .GetValue(bindingContext.ModelName).FirstValue;

        if (string.IsNullOrEmpty(value))
        {
            bindingContext.Result = ModelBindingResult.Success(Array.Empty<int>());
            return Task.CompletedTask;
        }

        var ints = value.Split(',', StringSplitOptions.RemoveEmptyEntries)
            .Select(s => int.TryParse(s.Trim(), out var n) ? (int?)n : null)
            .Where(n => n.HasValue)
            .Select(n => n!.Value)
            .ToArray();

        bindingContext.Result = ModelBindingResult.Success(ints);
        return Task.CompletedTask;
    }
}

// ── Apply with ModelBinder attribute ──────────────────────────────────────
[HttpGet]
public async Task<IActionResult> GetByIds(
    [FromQuery][ModelBinder(typeof(CommaDelimitedIntsBinder))] int[] ids,
    CancellationToken ct)
    => Ok(await _service.GetByIdsAsync(ids, ct));

Common Mistakes

Mistake 1 — Two [FromBody] parameters in one action (only one body allowed)

❌ Wrong — HTTP requests have one body; two [FromBody] parameters causes binding failure:

public async Task<IActionResult> Create([FromBody] PostRequest p, [FromBody] MetaRequest m) { }

✅ Correct — combine into a single wrapper request DTO.

Mistake 2 — Not configuring Kestrel size limit for file uploads (files over 30MB fail)

❌ Wrong — default Kestrel request size limit (30MB); large file uploads silently fail.

✅ Correct — explicitly configure MaxRequestBodySize for endpoints that accept large uploads.

🧠 Test Yourself

An action has both a route parameter int id and a complex type UpdatePostRequest request. With [ApiController], where does each come from without explicit binding attributes?