Minimal APIs in Depth Part 1: Parameter Binding Sources, Custom Binding (TryParse/BindAsync), [AsParameters], Forms and IFormFile
A minimal API decides where a parameter comes from in a fixed order: attributes, special types, BindAsync, TryParse, services and finally the body. We bind an ISBN to a type with TryParse, use BindAsync and [AsParameters], and see antiforgery in form binding and case-sensitive enum binding with real outputs.
Version note: The code in this post was built and run with .NET 10 (SDK 10.0.404).
Guess First
The book list of Bookstore, the online bookstore used as the shared example across this site's series, can be sorted. The sort order is an enum, and the query parameters are gathered in a single record:
public enum BookSort
{
Title,
Price,
PriceDescending,
}
app.MapGet("/api/books", ([AsParameters] BookQuery query) => query.Apply());
The Angular client sends GET /api/books?sort=price; note the lowercase. When an [ApiController] controller takes the same parameter as [FromQuery] BookSort sort, it gets Price. What does the minimal API do? Sort by price, fall back to the default Title, or return a 400? Bonus: what if you send ?sort=42?
In the previous post we read HttpContext by hand. In a minimal API you rarely do that, because the handler's parameter list is really a binding recipe: when the app starts, the framework looks at every parameter, decides where it comes from and compiles that decision once. We saw the basic rules in the .NET 6 era minimal API post; here we look at the full picture in .NET 10, binding your own types and form binding.
How the Source of a Parameter Is Decided
The parameter binding page on Learn describes the order like this. First come the explicit attributes: [FromRoute], [FromQuery], [FromHeader], [FromBody], [FromForm], [FromServices] and [AsParameters]. Without an attribute, special types are checked: HttpContext, HttpRequest, HttpResponse, ClaimsPrincipal, CancellationToken, IFormCollection, IFormFileCollection, IFormFile, Stream and PipeReader. Next, does the type have a static BindAsync method? If not, is the type string or does it have a static TryParse: if so, it comes from the route when its name appears in the route template, otherwise from the query string. Failing that, the framework asks whether the type is a service registered in the DI container. If nothing matches, the parameter is read from the body as JSON.
The practical result of this order: string and every type with a TryParse, such as int, Guid or DateOnly, come from the route or the query; services come without an attribute; everything else is the body. Arrays are supported for query strings and headers, which can carry the same key more than once: an int[] ids parameter reads ?ids=1&ids=2. Keyed services come with [FromKeyedServices] from the .NET 8 post. Let's use two shipping calculators in the same endpoint for an institutional order:
builder.Services.AddKeyedSingleton<IShippingCalculator, StandardShipping>("standard");
builder.Services.AddKeyedSingleton<IShippingCalculator, ExpressShipping>("express");
// ...
app.MapGet("/api/shipping/quote", (
InstitutionContext institution,
int quantity,
[FromKeyedServices("express")] IShippingCalculator express,
[FromKeyedServices("standard")] IShippingCalculator standard) =>
new
{
Unit = institution.Unit.Name,
Standard = standard.Calculate(quantity),
Express = express.Calculate(quantity),
});
$ curl "localhost:5238/api/shipping/quote?quantity=25" -H "X-Tenant-Id: 101"
{"unit":"Main Campus","standard":0,"express":149.90}
quantity is an int that isn't in the route, so it came from the query. Standard shipping is free for institutional orders of 20 copies or more. InstitutionContext, on the other hand, is neither a service nor the body; we'll see where it comes from shortly.
Your Own Type: Isbn with TryParse
For the endpoint that fetches a book by ISBN, taking the parameter as a string is easy, but then every handler has to check the ISBN again. Instead we turn the ISBN into a type and put the validation inside it:
public readonly record struct Isbn(string Value) : IParsable<Isbn>
{
// "978-0441013593" and "9780441013593" are the same ISBN; drop the hyphens and check the checksum
public static bool TryParse([NotNullWhen(true)] string? s, IFormatProvider? provider, out Isbn result)
{
result = default;
string digits = (s ?? "").Replace("-", "");
if (digits.Length != 13 || !digits.All(char.IsAsciiDigit))
{
return false;
}
int sum = 0;
for (int i = 0; i < 12; i++)
{
sum += (digits[i] - '0') * (i % 2 == 0 ? 1 : 3);
}
if ((10 - sum % 10) % 10 != digits[12] - '0')
{
return false;
}
result = new Isbn(digits);
return true;
}
public static Isbn Parse(string s, IFormatProvider? provider) =>
TryParse(s, provider, out Isbn result) ? result : throw new FormatException($"'{s}' is not a valid ISBN-13.");
public override string ToString() => Value;
}
app.MapGet("/api/books/{isbn}", Results<Ok<Book>, NotFound> (Isbn isbn, BookCatalog catalog) =>
catalog.Find(isbn) is { } book ? TypedResults.Ok(book) : TypedResults.NotFound());
app.MapGet("/api/books/lookup", (Isbn[] isbn, BookCatalog catalog) =>
isbn.Select(i => catalog.Find(i)?.Title ?? $"{i}: not found"));
$ curl localhost:5238/api/books/9780441013593
{"id":1,"isbn":"978-0441013593","title":"Dune","price":450,"category":"science-fiction"}
$ curl -i localhost:5238/api/books/978-0441013590
HTTP/1.1 400 Bad Request
Content-Length: 0
$ curl "localhost:5238/api/books/lookup?isbn=978-0547928227&isbn=9780141439549&isbn=9780000000002"
["The Hobbit","Middlemarch","9780000000002: not found"]
The hyphenated and unhyphenated forms found the same book; the ISBN with a wrong check digit got a 400 without ever reaching the handler (the last output is from the Production environment). Isbn[] went through TryParse item by item, too. The framework only looks at the method signature: TryParse(string, out T) or TryParse(string, IFormatProvider, out T). Implementing IParsable<T> isn't required, but it is a good habit because the compiler then checks the signatures. /api/books/lookup and /api/books/{isbn} didn't clash because, as we saw in the routing post, a literal segment wins over a parameter segment. One detail: a minimal API passes the invariant culture to TryParse as the IFormatProvider; we'll talk about how culture affects binding in the localization post.
BindAsync: Let the Type Bind Itself
TryParse takes a single string. When you need to build a value from a mix of headers, cookies and services, a static BindAsync fits better. In the previous post we resolved X-Tenant-Id in a middleware and put it into Items. A parameter type can do the same job:
public record InstitutionContext(InstitutionUnit Unit)
{
// The parameter type takes over the binding itself
public static ValueTask<InstitutionContext?> BindAsync(HttpContext context)
{
InstitutionDirectory directory = context.RequestServices.GetRequiredService<InstitutionDirectory>();
string? raw = context.Request.Headers[TenantMiddleware.HeaderName];
InstitutionContext? result = int.TryParse(raw, out int unitId) && directory.Find(unitId) is { } unit
? new InstitutionContext(unit)
: null;
return ValueTask.FromResult(result);
}
}
If BindAsync returns null and the parameter isn't nullable, the framework returns 400; if it throws, 500. A request without the header got a 400 without a body in Production. When I turned the log level up to Debug, the reason showed up as well:
dbug: Microsoft.AspNetCore.Http.RequestDelegateFactory[4]
Required parameter "InstitutionContext institution" was not provided from InstitutionContext.BindAsync(HttpContext).
Middleware or BindAsync? A middleware runs by path and can answer without the request ever reaching an endpoint; it suits protecting a whole URL area. BindAsync runs only on endpoints that ask for that parameter, is visible in the handler's signature and is easy to test. Using both at once is redundant; in Bookstore we keep the middleware for the institutional area and use BindAsync on the odd endpoint outside the institutional path. The BindAsync(HttpContext context, ParameterInfo parameter) overload also gives you the parameter's name and attributes. The IBindableFromHttpContext<TSelf> interface, built on C# 11's static abstract members, lets the compiler check the same signature.
A Parameter Object with [AsParameters]
As a list endpoint grows, so does the handler signature: category, search text, page, page size, sort order, plus a service. [AsParameters], which arrived in .NET 7, lets you gather them in one type:
public record BookQuery(
BookCatalog Catalog,
string? Category,
[FromQuery(Name = "q")] string? Search,
[Range(1, 50)] int PageSize = 10,
int Page = 1,
BookSort Sort = BookSort.Title)
{
public IEnumerable<Book> Apply()
{
IEnumerable<Book> books = Catalog.All;
if (Category is not null)
{
books = books.Where(b => b.Category == Category);
}
if (Search is not null)
{
books = books.Where(b => b.Title.Contains(Search, StringComparison.OrdinalIgnoreCase));
}
books = Sort switch
{
BookSort.Price => books.OrderBy(b => b.Price),
BookSort.PriceDescending => books.OrderByDescending(b => b.Price),
_ => books.OrderBy(b => b.Title),
};
return books.Skip((Page - 1) * PageSize).Take(PageSize);
}
}
[AsParameters] binds each constructor parameter (or property) of the type as if it were a separate handler parameter, and the same rules apply: BookCatalog as a service, the string? and int values from the query, Search from q thanks to the name in the attribute. Parameters with a default value are optional; if Page is missing it is 1. A non-nullable parameter without a default gets you a 400 when it is missing: the quote request without quantity came back in Development with the message Required parameter "int quantity" was not provided from query string. Learn stresses that [AsParameters] isn't recursive model binding; it won't fill nested complex types from the query. Note also that it recommends a struct over a record for performance. AddValidation() from the .NET 10 post recognizes the attributes inside this type too:
$ curl "localhost:5238/api/books?category=classics&sort=Price"
[{"id":2,"isbn":"978-0141439587","title":"Emma","price":280,"category":"classics"},{"id":4,"isbn":"978-0141439549","title":"Middlemarch","price":390,"category":"classics"}]
$ curl "localhost:5238/api/books?pageSize=100"
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"One or more validation errors occurred.","status":400,"errors":{"PageSize":["The field PageSize must be between 1 and 50."]},"traceId":"00-1ffb852c2e8cbe6ca750b2bc7b0a0167-606e571dfebcebf4-00"}
Now the answer to the guess. ?sort=price returned 400. In Development the body said:
$ curl "localhost:5238/api/books?sort=price"
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"Microsoft.AspNetCore.Http.BadHttpRequestException","status":400,"detail":"Failed to bind parameter \"BookSort Sort\" from \"price\".", ...}
Minimal APIs parse enums case-sensitively; Price works, price doesn't. On the controller side the same value bound to Price, because MVC's enum binding ignores case. Numbers are the interesting part: ?sort=1 became Price on both sides, while ?sort=42 returned 200 in the minimal API, and the value 42, which isn't in the enum, fell into the default arm of the switch, that is, sorting by title. So if you take an enum from the query, you have to handle both case sensitivity and undefined numbers yourself.
Forms, IFormFile and Antiforgery
Let's use form binding to upload a book cover. IFormFile is a special type, so it needs no attribute; for the plain fields of the form we write [FromForm]:
builder.Services.AddAntiforgery();
// ...
app.UseAntiforgery();
app.MapGet("/api/antiforgery/token", (IAntiforgery antiforgery, HttpContext context) =>
{
AntiforgeryTokenSet tokens = antiforgery.GetAndStoreTokens(context);
return new { tokens.FormFieldName, tokens.RequestToken };
});
app.MapPost("/api/books/{isbn}/cover", (Isbn isbn, IFormFile file, [FromForm] string? altText) =>
new { Isbn = isbn.Value, file.FileName, file.ContentType, file.Length, AltText = altText });
Since .NET 8 every minimal API endpoint that reads from a form automatically carries antiforgery metadata, and the UseAntiforgery() middleware validates the token. A request without the token was rejected:
$ curl -i -X POST localhost:5238/api/books/978-0441013593/cover -F "file=@cover.png;type=image/png" -F "altText=Dune cover"
HTTP/1.1 400 Bad Request
Content-Length: 0
dbug: Microsoft.AspNetCore.Http.RequestDelegateFactory[9]
Invalid anti-forgery token found when reading parameter "IFormFile file" from the request body as form.
After I fetched the token and its matching cookie from the token endpoint (curl -c) and then sent the cookie (-b) together with the token in the __RequestVerificationToken form field, the request went through:
{"isbn":"9780441013593","fileName":"cover.png","contentType":"image/png","length":7,"altText":"Dune cover"}
I tried deleting the UseAntiforgery() line; even with AddAntiforgery() still in place the request returned 500, and the log said: Endpoint HTTP: POST /api/books/{isbn}/cover contains anti-forgery metadata, but a middleware was not found that supports anti-forgery. The message also reminds you that the middleware must come after UseAuthentication() and UseAuthorization().
The attack antiforgery tries to stop is CSRF. It relies on the browser sending cookies automatically. If your API authenticates only through the Authorization: Bearer header, that is, without cookie authentication, the browser won't add that header on its own; in that case calling .DisableAntiforgery() on the endpoint is a reasonable decision. What matters is making it knowingly. Size and type checks for the cover file, safe file names and streaming follow the rules from the file upload post; minimal APIs change nothing there. For several files you can use IFormFileCollection, and to fill a DTO from form fields a complex type such as [FromForm] CoverUpload upload.
Binding Errors: What's Behind the 400
Every 400 in this post has two faces. In Development RouteHandlerOptions.ThrowOnBadRequest is true; the binding error is thrown as a BadHttpRequestException, and because AddProblemDetails() is on, the developer exception page writes it as ProblemDetails with the exception details. In Production the setting is false; the framework quietly returns a 400 without a body and writes the reason only to the Debug-level log:
dbug: Microsoft.AspNetCore.Http.RequestDelegateFactory[3]
Failed to bind parameter "BookSort Sort" from "price".
From the client's point of view, a 400 without a body isn't much help. You can fill that gap in two places. The status code pages middleware adds a ProblemDetails body to error responses that have none; we'll set it up in the built-in middleware post. And with the change we saw in the .NET 11 post, endpoint filters can run after a binding failure and write their own message. The table on Learn also summarizes the status code per failure: TryParse returning false and BindAsync returning null give 400, BindAsync throwing gives 500, malformed JSON gives 400, and a body with a content type other than application/json gives 415.
The Controller Equivalent
In controllers the same jobs are done with the tools from the model binding post, with a few differences. Instead of TryParse, MVC looks at TypeConverters and its own IModelBinders; to bind a type like Isbn in a controller you write either a TypeConverter or a model binder. The counterpart of BindAsync is again a custom model binder. The counterpart of [AsParameters] is [FromQuery] BookQuery query; controllers have supported that since .NET 6, and they can even fill nested complex types. Binding errors in a controller go to ModelState rather than an exception, and [ApiController] automatically returns a ValidationProblemDetails; so a controller shows the error message to the client by default, and a minimal API in Production does not. Antiforgery isn't automatic in controllers either; you turn it on with attributes such as [ValidateAntiForgeryToken].
Common Pitfalls
First, trying to read two parameters from the body. When I added an endpoint with the signature (CartItem item, InstitutionOrderRequest order), the app compiled and started, but on the first request this exception came while the endpoints were being built, and from then on every request in the app returned 500:
System.InvalidOperationException: Failure to infer one or more parameters.
Below is the list of parameters that we found:
Parameter | Source
---------------------------------------------------------------------------------
item | Body (Inferred)
order | UNKNOWN
The body is a single stream that can be read once; if you need two DTOs, combine them in one request type.
Second, case sensitivity when binding a query string to an enum. We saw it in the guess: price gives 400, while 42 is accepted. If the Angular side sends enums in lowercase, either settle the contract with the client or wrap the enum in a type with its own TryParse.
Third, turning antiforgery off on a form endpoint without knowing why. Seeing the 400 and pasting in .DisableAntiforgery() opens the CSRF door in an app that uses cookie authentication. Look at how authentication is done first.
Fourth, trusting ThrowOnBadRequest and never testing Production behavior. A client team that sees detailed messages in Development is surprised by bodiless 400s in Production; run your integration tests with Production settings too.
A Small Experiment
★☆☆ Add a {isbn:minlength(13)} constraint to the /api/books/{isbn} route and send an unhyphenated 12-digit ISBN. Did the status code change? Observe that the constraint affects route matching, while TryParse affects binding.
★★☆ Write a BookSortParameter record struct that wraps BookSort: inside TryParse use Enum.TryParse<BookSort>(s, ignoreCase: true, out ...) and Enum.IsDefined. Use it in BookQuery and see ?sort=price return 200 and ?sort=42 return 400.
★★★ Change the cover upload endpoint to take [FromForm] CoverUpload upload (IFormFile File, string? AltText, [Range(1, 5)] int Position). Check with curl whether AddValidation() validates this type coming from the form and whether antiforgery is still active.
Binding is the first half of the road to the handler. In the next post we move to the second half: organizing endpoints with route groups, putting endpoint filters in between, the TypedResults catalog and how all of this shows up in the OpenAPI document.