Custom Model Binders in ASP.NET Core
Model binding maps HTTP request data — route values, query strings, headers, and request bodies — to action method parameters. The built-in binders handle most cases, but sometimes your API needs to accept data in formats the defaults can't parse. That's where custom model binders come in.
When You Need Custom Binding
Consider an endpoint that accepts multiple IDs as a comma-separated query parameter:
GET /api/products?ids=1,5,12,37
The default binder expects repeated parameters (?ids=1&ids=5&ids=12). A custom binder lets you support the comma-separated format instead.
Building a Model Binder
Implement IModelBinder:
public class CommaSeparatedModelBinder : IModelBinder
{
public Task BindModelAsync(ModelBindingContext bindingContext)
{
var modelName = bindingContext.ModelName;
var valueProviderResult = bindingContext.ValueProvider.GetValue(modelName);
if (valueProviderResult == ValueProviderResult.None)
{
return Task.CompletedTask;
}
var value = valueProviderResult.FirstValue;
if (string.IsNullOrWhiteSpace(value))
{
bindingContext.Result = ModelBindingResult.Success(Array.Empty<int>());
return Task.CompletedTask;
}
var values = value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
var parsed = new List<int>();
foreach (var item in values)
{
if (int.TryParse(item, out var id))
{
parsed.Add(id);
}
else
{
bindingContext.ModelState.AddModelError(modelName, $"'{item}' is not a valid integer.");
return Task.CompletedTask;
}
}
bindingContext.Result = ModelBindingResult.Success(parsed.ToArray());
return Task.CompletedTask;
}
}
Apply it to a parameter:
[HttpGet]
public IActionResult GetByIds(
[ModelBinder(BinderType = typeof(CommaSeparatedModelBinder))] int[] ids)
{
var products = _repository.GetByIds(ids);
return Ok(products);
}
Model Binder Providers
If you want a binder to apply automatically based on type rather than using the attribute every time, create a provider:
public class CommaSeparatedModelBinderProvider : IModelBinderProvider
{
public IModelBinder? GetBinder(ModelBinderProviderContext context)
{
if (context.Metadata.ModelType == typeof(int[])
&& context.BindingInfo.BindingSource == BindingSource.Query)
{
return new BinderTypeModelBinder(typeof(CommaSeparatedModelBinder));
}
return null;
}
}
Register the provider — order matters, as the first matching provider wins:
builder.Services.AddControllers(options =>
{
options.ModelBinderProviders.Insert(0, new CommaSeparatedModelBinderProvider());
});
Binding Complex Types from Route Values
A more interesting use case is binding a composite key from a route segment:
public record ProductVariantKey(int ProductId, string Colour);
public class ProductVariantKeyBinder : IModelBinder
{
public Task BindModelAsync(ModelBindingContext bindingContext)
{
var value = bindingContext.ValueProvider.GetValue(bindingContext.ModelName).FirstValue;
if (value is null)
{
return Task.CompletedTask;
}
// Expect format: "123-red"
var parts = value.Split('-', 2);
if (parts.Length != 2 || !int.TryParse(parts[0], out var productId))
{
bindingContext.ModelState.AddModelError(
bindingContext.ModelName, "Expected format: {productId}-{colour}");
return Task.CompletedTask;
}
var key = new ProductVariantKey(productId, parts[1]);
bindingContext.Result = ModelBindingResult.Success(key);
return Task.CompletedTask;
}
}
Apply it via an attribute on the type itself:
[ModelBinder(BinderType = typeof(ProductVariantKeyBinder))]
public record ProductVariantKey(int ProductId, string Colour);
[HttpGet("variants/{key}")]
public IActionResult GetVariant(ProductVariantKey key)
{
// key.ProductId = 123, key.Colour = "red" for /variants/123-red
return Ok(key);
}
Binding from Headers
Custom binders can pull data from any source. Here's one that binds a correlation ID from a header:
public class CorrelationIdBinder : IModelBinder
{
public Task BindModelAsync(ModelBindingContext bindingContext)
{
var httpContext = bindingContext.HttpContext;
var correlationId = httpContext.Request.Headers["X-Correlation-Id"].FirstOrDefault()
?? Guid.NewGuid().ToString();
bindingContext.Result = ModelBindingResult.Success(correlationId);
return Task.CompletedTask;
}
}
Using IParsable in .NET 7+
For minimal APIs, .NET 7 introduced support for IParsable<T>. If your type implements it, minimal API binding works automatically:
public record DateRange(DateOnly From, DateOnly To) : IParsable<DateRange>
{
public static DateRange Parse(string s, IFormatProvider? provider)
{
var parts = s.Split("..");
return new DateRange(DateOnly.Parse(parts[0]), DateOnly.Parse(parts[1]));
}
public static bool TryParse(string? s, IFormatProvider? provider, out DateRange result)
{
result = default!;
if (s is null) return false;
var parts = s.Split("..");
if (parts.Length != 2) return false;
if (DateOnly.TryParse(parts[0], out var from) && DateOnly.TryParse(parts[1], out var to))
{
result = new DateRange(from, to);
return true;
}
return false;
}
}
// Works automatically: GET /orders?range=2025-01-01..2025-03-31
app.MapGet("/orders", (DateRange range) => Results.Ok($"From {range.From} to {range.To}"));
Key Points
- Custom model binders let you handle any parameter format — comma-separated lists, composite keys, custom date ranges.
- Use
IModelBinderProviderto apply binders by convention instead of per-parameter attributes. - Place the
[ModelBinder]attribute on the type for reusable binding. - For minimal APIs, consider
IParsable<T>— it's simpler and doesn't require a separate binder class. - Always validate input and add model state errors for malformed data.