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");
}
[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.[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.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.