Model Binding in Minimal API

In web development, model binding refers to the process of extracting data from an HTTP request—such as route parameters, query strings, headers, cookies, or body content—and mapping it to native programming language objects or structures for use in code execution.

Model binding is not exclusive to ASP.NET Core MVC controllers or Minimal APIs. Similar mechanisms exist in other modern web frameworks, including Java Spring, Python FastAPI (using Pydantic models for request parsing and validation), and some JavaScript/TypeScript frameworks such as NestJS and Express. These frameworks provide automatic mapping of HTTP request data to native language objects, often including validation, type conversion, and nested/complex object support, much like ASP.NET Core.

This article focuses primarily on model binding in ASP.NET Core Minimal API endpoints.

Basics of Model Binding

In ASP.NET Core Minimal APIs, simple types such as integers, strings, Booleans, and dates are automatically mapped from the appropriate source, while complex types—classes, records, or nested objects—are generally bound from the body, with full support for collections and hierarchical structures. Attached to parameters in the lambda, attributes like FromQuery, FromRoute, FromHeader, or FromBody allow explicit control over the binding source.

Model binding occurs after routing has identified the endpoint and immediately before the delegate executes, providing a fully populated, type-safe object ready for use in code. Note that starting with .NET 10, model validation can be applied automatically to bound parameters or complex types using data annotations, ensuring invalid input is detected before the delegate runs, and additional validation frameworks such as FluentValidation can also be integrated for more advanced scenarios.

Model binding is fundamentally name-based, though there are some nuances depending on the type being bound and the source. Let’s break it down carefully.

Name-based Matching

When a request comes in, the framework looks at the names of the endpoint parameters (or properties of complex types) and tries to match them with the names of values in the request. Let’s consider the code below.

app.MapGet("/users/{id}", (int id) => ...);

The id parameter in the lambda signature matches the {id} route template name. Similarly, query string parameters or headers are automatically mapped to parameters with the same name, with case-insensitive matching. When a request contains a body, typically in JSON or form data, ASP.NET Core can bind it to complex types like classes, records, or structs. The framework does this by matching the property names of the target type to the keys in the body. Let’s consider the following minimal application.

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

public class DataRange
{
    public DateTime Start { get; set; }
    public DateTime End { get; set; }
}

// Minimal API endpoint using a lambda
app.MapPost("/daterange", (DataRange range) =>
{
    // 'range' is automatically bound from the JSON body
    return Results.Ok(new
    {
        Start = range.Start,
        End = range.End
    });
});

app.Run();

The /daterange endpoint expects incoming data to be mapped to an instance of the DataRange type. For example, a JSON payload like

{
  "start": "2025-01-01",
  "end": "2025-01-31"
}

or form data such as

start=2025-01-01&end=2025-01-31

will be automatically bound. ASP.NET Core reads the request and maps “start” to DataRange.Start and “end” to DataRange.End. This mapping is case-insensitive, so “Start” or “START” would work equally well. For classes with nested properties, ASP.NET Core can recursively match keys to nested objects.

It is important to note that this automatic complex type binding behaves differently when the source is not the request body. If you want all the public properties of a complex type to be treated as individual parameters—bound from route, query, headers, or form—the parameter must be decorated with AsParameters. Here’s an example:

public class FilterOptions
{
    public int Page { get; set; }
    public int PageSize { get; set; }
}

app.MapGet("/items", ([AsParameters] FilterOptions options) =>
{
    // options.Page and options.PageSize are 
    // automatically bound from query string and/or other sources
    // except the body
    return Results.Ok(options);
});

Curiously, this is the opposite of the behavior in ASP.NET MVC, where the default is to treat each property as an individual parameter, and binding from the body requires FromBody. In Minimal APIs, FromBody is generally not required, except when you want to be explicit, particularly if multiple forms of binding are being used in the same endpoint.

Custom Binding with Complex Types

In ASP.NET Core Minimal APIs, model binding for endpoint parameters follows a clear priority. The first mechanism checked is BindAsync. If the target type defines a static BindAsync method, the framework delegates binding entirely to that method. This allows the type itself to control how it is created from the request, including reading multiple values, performing validation, or combining data from different sources. For example:

public record GeoCoordinate(double Latitude, double Longitude)
{
    public static ValueTask<GeoCoordinate> BindAsync(
                      HttpContext context, ParameterInfo param)
    {
        var lat = double.Parse(context.Request.Query["lat"]);
        var lon = double.Parse(context.Request.Query["lon"]);
        return ValueTask.FromResult(new GeoCoordinate(lat, lon));
    }
}

app.MapGet("/location", (GeoCoordinate coord) => Results.Ok(coord));

Here, GeoCoordinate.BindAsync fully controls how the coord parameter is created from the query string.

If no BindAsync method is present, Minimal APIs falls back to TryParse for processing strings into more specific scalar (or even complex) types. The issue is standard parsers only handle conventional string formats that can be mapped to Booleans, dates or numbers. For example, if a client sends a date in a compact yyyymmdd format:

app.MapGet("/birthday", (string date) =>
{
    // Custom logic since TryParse won't handle yyyymmdd automatically
    if (DateTime.TryParseExact(date, 
          "yyyyMMdd", 
          null,
          System.Globalization.DateTimeStyles.None, 
          out var parsedDate))
    {
        return Results.Ok($"Your birthday is {parsedDate:yyyy-MM-dd}");
    }
    return Results.BadRequest("Invalid date format. Use yyyymmdd.");
});

A request like

/birthday?date=20251025

will bind the string “20251025” to the date parameter. Because DateTime.TryParse does not recognize the compact format, we manually convert it using DateTime.TryParseExact (or any other handmade algorithm).

In short, Minimal API binding first defers to BindAsync for full control, then uses TryParse for simple types, and finally falls back to body/property binding for complex objects.  

Binding System Types

Some framework types are directly available as parameters without any explicit binding attributes. Here’s an example.

app.MapGet("/info", (HttpRequest req, HttpResponse res) =>
{
    var method = req.Method;
    var path = req.Path;
    res.Headers.Add("X-Info", "Minimal API demo");
    return Results.Ok(new { Method = method, Path = path });
});

As you can see, you can include HttpContext, HttpRequest, or HttpResponse in your endpoint delegate, and ASP.NET Core automatically injects the current context or request/response objects.

Dependency Injection in Minimal API

In Minimal APIs, services can be injected directly into endpoint parameters. The attribute FromServices exists to mark a parameter explicitly for DI, but it is largely redundant. The framework, in fact, automatically injects any type that is unambiguously registered in the DI container.

builder.Services.AddSingleton<MyService>();

app.MapGet("/greet", (MyService service) =>
{
    return Results.Ok(service.GetGreeting());
});

Here, MyService is injected automatically into the endpoint because there is only one registration of that type. What if multiple registrations exist?

builder.Services.AddSingleton<MyService>();
builder.Services.AddSingleton<MyService>(new MyService("Special"));

In this case, automatic injection fails because the type is ambiguous and FromServices alone does not help; you must resolve the desired instance manually using IServiceProvider.

app.MapGet("/greet", (IServiceProvider sp) =>
{
    var service = sp.GetServices<MyService>()
                    .First(s => s.Name == "Special");
    return Results.Ok(service.GetGreeting());
});

In practice, Minimal APIs make DI automatic and convenient when the type is unambiguous. FromServices exists mainly as a declarative marker, but it cannot overcome ambiguity, so its usefulness is limited to documenting intent rather than enabling functionality.

Summary

Minimal APIs map HTTP request data to endpoint parameters using a clear priority: first, a type’s BindAsync method (if defined) handles custom or complex binding; next, TryParse converts simple scalar types like integers, Booleans, GUIDs, and dates. Finally, body or property binding deserializes JSON or matches property names for complex objects. Attributes like AsParameters and FromBody give explicit control, while certain framework types (HttpContext, HttpRequest, HttpResponse) are automatically injected.

Services from DI are also injected automatically if the type is unambiguous; FromServices exists mainly as a declarative marker and cannot resolve multiple registrations. This system makes Minimal APIs flexible, type-safe, and concise, handling both standard and advanced scenarios with minimal boilerplate.

Published by D. Esposito

Software person since 1992

Leave a comment