Minimal APIs, controllers, routing and binding
The same endpoint written both ways, what a route constraint really does, and how a handler parameter's value source is chosen.
After this lesson you can
- Write the same endpoint as a minimal API and as a controller action
- Explain why a failed route constraint gives 404, not 400
- Predict the binding source of an unattributed parameter
- Argue for one style or the other on a new service
// Minimal API
app.MapGet("/orders/{id:int}", async (int id, ShopDb db) =>
await db.Orders.FindAsync(id) is { } order ? Results.Ok(order) : Results.NotFound());
// Controller
[ApiController]
[Route("orders")]
public class OrdersController(ShopDb db) : ControllerBase
{
[HttpGet("{id:int}")]
public async Task<ActionResult<Order>> Get(int id) =>
await db.Orders.FindAsync(id) is { } order ? order : NotFound();
}
Both sit on the same routing, dependency injection, authentication and
authorization. Controllers add MVC filters and the [ApiController]
conventions: attribute routing is required, an invalid model returns 400
automatically, and binding sources are inferred. Minimal APIs are
lighter: route groups (MapGroup), endpoint filters, TypedResults for
accurate OpenAPI metadata, and support for native AOT, which MVC
controllers do not have.
Route constraints select, they do not validate
A constraint such as {id:int} is part of matching. If the segment is
not an integer, the route does not match at all, and with no other
candidate the request ends at the 404 terminal. Without the constraint,
the route matches and binding "abc" to an int parameter fails with a
400. Use constraints to tell routes apart, and validation to reject bad
input. The model below is a tiny matcher that only knows literals,
{name} and {name:int}.
public static class Solution{ static string Match(string template, string path) { var t = template.Trim('/').Split('/'); var p = path.Trim('/').Split('/'); if (t.Length != p.Length) return path + " -> no match"; var values = new List<string>(); for (int i = 0; i < t.Length; i++) { if (t[i].StartsWith("{")) { var inner = t[i].Trim('{', '}').Split(':'); if (inner.Length == 2 && inner[1] == "int" && !int.TryParse(p[i], out _)) return path + " -> no match"; values.Add(inner[0] + "=" + p[i]); } else if (!string.Equals(t[i], p[i], StringComparison.OrdinalIgnoreCase)) return path + " -> no match"; } return path + " -> " + string.Join(", ", values); } public static List<string> Run() => new() { Match("/orders/{id:int}", "/orders/42"), Match("/orders/{id:int}", "/orders/abc"), Match("/orders/{id}", "/orders/abc"), Match("/orders/{id:int}", "/orders/42/lines"), };}Where a parameter's value comes from
An attribute ([FromRoute], [FromQuery], [FromBody],
[FromHeader], [FromServices], [FromForm]) always decides. Without
one, a minimal API handler infers the source roughly like this:
- Well-known types:
HttpContext,HttpRequest,HttpResponse,CancellationToken,ClaimsPrincipal. - Simple types (
int,string,Guid,DateTimeand anything with aTryParse): from the route if the template has a parameter of that name, otherwise from the query string. - A type registered in the container: from services.
- Anything else: deserialised from the JSON body. Only one parameter may come from the body.
The list leaves out rarer cases, such as a type that binds itself with a
static BindAsync method. In a minimal API, a parameter that is not
nullable and has no default value is required: when the request does
not supply it, the endpoint answers 400 without running the handler.
int?, or a default value make it optional. A value that does
not parse, such as for an or an , is a 400 either way. The model below applies the four rules to a
POST handler's parameters.
public static class Solution{ static readonly HashSet<string> Special = new() { "HttpContext", "CancellationToken", "ClaimsPrincipal" }; static readonly HashSet<string> Simple = new() { "int", "string", "Guid", "DateTime" }; static string Source(string name, string type, HashSet<string> routeParams, HashSet<string> services) { if (Special.Contains(type)) return "special"; if (Simple.Contains(type)) return routeParams.Contains(name) ? "route" : "query"; if (services.Contains(type)) return "services"; return "body"; } public static List<string> Run() { // app.MapPost("/shops/{shopId}/orders", // (int shopId, string? coupon, CreateOrder body, ShopDb db, CancellationToken ct) => ...) var route = new HashSet<string> { "shopId" }; var services = new HashSet<string> { "ShopDb" }; var parameters = new[] { ("shopId", "int"), ("coupon", "string"), ("body", "CreateOrder"), ("db", "ShopDb"), ("ct", "CancellationToken"), }; return parameters.Select(p => p.Item1 + ": " + Source(p.Item1, p.Item2, route, services)).ToList(); }}Common mistakes
- Using a route constraint as input validation.
/orders/abcagainst{id:int}is a 404, which tells the client the resource does not exist rather than that the request was malformed. - Checking whether a name appears anywhere in the template.
ordersin/orders/{id}is a literal segment, not a route parameter, so a parameter calledordersbinds from the query. - Two complex parameters on one handler. Both infer the body, and the
framework rejects the endpoint with an
InvalidOperationExceptioninstead of guessing. Merge them into one request type. - Expecting automatic 400s for invalid models in minimal APIs on .NET 9.
That behaviour comes from
[ApiController]; minimal APIs on .NET 9 need an endpoint filter or explicit validation.
What interviewers ask
- Controllers or minimal APIs for a new service, and why? Either is a defensible answer; name concrete trade-offs: filters and conventions versus less ceremony, AOT and route groups.
- A client calls
/orders/abcand gets 404. Why not 400? Theintconstraint is part of route matching, so no endpoint matched. - How does ASP.NET Core decide where a parameter comes from? Attributes first; otherwise special types, route or query for simple types, services, and the body for complex types.
Exercise · Pro
404, 400 or 200?
Exercises are free in the first 3 lessons of every course. From here on they are part of Pro and Sprint; the lesson and its runnable examples stay open.
Upgrade