Minimal APIs
Key Points
- Minimal APIs are lambda-based endpoint definitions, introduced in .NET 6 and reaching MVC parity in .NET 8/9.
MapGrouplets you group endpoints with shared metadata (auth, rate limiting, tags). Essential for organization.- Endpoint filters (
AddEndpointFilter) are the Minimal-APIs equivalent of MVC action filters — for cross-cutting validation, logging, etc. TypedResultsreturns strongly-typedIResult(e.g.,TypedResults.Ok<T>(...)) — better OpenAPI generation and tooling.- Parameter binding is implicit by source: from route, query, body (one), services. Use
[FromRoute],[FromQuery],[FromBody],[FromServices]for explicit control. - AOT-friendly — Minimal APIs were designed with NativeAOT in mind; works with the request-delegate generator.
Concepts (deep dive)
Basic shape
var app = WebApplication.Create();
app.MapGet("/", () => "Hello, world!");
app.MapGet("/orders/{id:int}", async (int id, IRepo repo) =>
await repo.GetAsync(id) is { } o ? Results.Ok(o) : Results.NotFound());
app.MapPost("/orders", async (Order order, IRepo repo, HttpContext ctx) =>
{
var saved = await repo.AddAsync(order);
return Results.Created($"/orders/{saved.Id}", saved);
});
await app.RunAsync();
Each Map* returns a RouteHandlerBuilder for fluent metadata.
Parameter binding rules
| Source | When |
|---|---|
| Route | Name matches a route token (e.g., {id}) |
| Query | Simple type (int, string, etc.) not in route |
| Body | Complex type (one max per endpoint, by default) |
| Services | Type registered in DI |
| Header | [FromHeader] attribute |
| Form | [FromForm] attribute |
app.MapGet("/orders/{id}", (
int id, // [FromRoute]
string? sortBy, // [FromQuery]
[FromHeader] string traceId,
[FromServices] IRepo repo) => /* ... */);
TypedResults for typed returns
app.MapGet("/orders/{id:int}",
Results<Ok<Order>, NotFound> (int id, IRepo repo) =>
repo.Get(id) is { } o ? TypedResults.Ok(o) : TypedResults.NotFound());
Results<T1, T2, ...> is a discriminated union of possible result types. OpenAPI generators use it to produce accurate response schemas.
Plain Results.Ok (untyped) returns IResult; TypedResults.Ok<T>(value) returns the strongly-typed Ok<T>. Prefer TypedResults in new code.
MapGroup
var orders = app.MapGroup("/api/v1/orders")
.WithTags("Orders")
.RequireAuthorization()
.RequireRateLimiting("api-tier")
.WithOpenApi();
orders.MapGet("/", GetAll);
orders.MapGet("/{id:int}", GetById);
orders.MapPost("/", Create);
orders.MapPut("/{id:int}", Update);
orders.MapDelete("/{id:int}", Delete);
All endpoints inherit the group's metadata. Cleaner than per-endpoint repetition.
Endpoint filters
app.MapPost("/orders", Create)
.AddEndpointFilter(async (ctx, next) =>
{
var sw = Stopwatch.StartNew();
var result = await next(ctx);
Logger.LogInformation("create took {Ms}ms", sw.ElapsedMilliseconds);
return result;
});
// Or class-based:
public class ValidationFilter : IEndpointFilter
{
public async ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext ctx,
EndpointFilterDelegate next)
{
var arg = ctx.GetArgument<Order>(0);
var errors = Validator.Check(arg);
if (errors.Count > 0) return Results.ValidationProblem(errors);
return await next(ctx);
}
}
app.MapPost("/orders", Create).AddEndpointFilter<ValidationFilter>();
Filters run before/after the handler. Class-based filters are DI-resolved per request.
Validation (.NET 8+ FluentValidation-friendly)
app.MapPost("/orders", async (CreateOrderRequest req, IValidator<CreateOrderRequest> v) =>
{
var result = await v.ValidateAsync(req);
if (!result.IsValid)
return Results.ValidationProblem(result.ToDictionary());
/* ... */
});
Or built-in validation via [ValidationProblem]-aware filters; Minimal APIs gained native validation in modern releases.
Streaming responses
app.MapGet("/big-export", async (HttpContext ctx, CancellationToken ct) =>
{
ctx.Response.ContentType = "application/json";
await ctx.Response.StartAsync(ct);
await using var writer = new Utf8JsonWriter(ctx.Response.BodyWriter);
writer.WriteStartArray();
await foreach (var item in repo.StreamAsync(ct))
{
JsonSerializer.Serialize(writer, item);
await writer.FlushAsync(ct);
}
writer.WriteEndArray();
});
For large responses, stream directly to Response.BodyWriter (a PipeWriter) — no intermediate buffering.
Or async-enumerable for newline-delimited JSON:
app.MapGet("/stream", (IRepo r, CancellationToken ct) => r.StreamAsync(ct));
// Returns IAsyncEnumerable; framework streams it
OpenAPI integration
builder.Services.AddOpenApi(); // .NET 9+ built-in OpenAPI
var app = builder.Build();
app.MapOpenApi(); // /openapi.json
app.MapScalarApiReference(); // optional: Scalar UI
app.MapGet("/orders/{id:int}", GetOrder)
.WithName("GetOrder")
.WithSummary("Retrieve an order by ID")
.WithDescription("Returns 404 if order does not exist")
.Produces<Order>(200)
.Produces(404);
In .NET 9+, the built-in OpenAPI generator replaces Swashbuckle for Minimal APIs; faster, more accurate, AOT-friendly.
Middleware vs endpoint filters
| Aspect | Middleware | Endpoint filter |
|---|---|---|
| Runs | Every request through pipeline | Only for matched endpoint |
| Knows the handler | No | Yes (params, return) |
| Setup | app.Use* | .AddEndpointFilter |
| Use | Cross-cutting (auth, logging) | Endpoint-specific (validation, args) |
Minimal APIs vs MVC
| Aspect | Minimal APIs | MVC controllers |
|---|---|---|
| Boilerplate | Minimal | More |
| Filters | IEndpointFilter | IActionFilter, etc. |
| Model binding | Convention; attributes for override | Same |
| Versioning | MapGroup + Asp.Versioning | Attributes |
| Razor views | Not supported | Yes |
| AOT | First-class | Improving but more pitfalls |
| Convention discovery | None (manual Map*) | Auto from attributes |
For a pure JSON API: prefer Minimal APIs in 2026. For server-rendered Razor or complex action conventions: stay with MVC.
Code: correct vs wrong
❌ Wrong: complex types as query strings without [AsParameters]
✅ Correct: AsParameters
[AsParameters] flattens the type's properties into the parameter list — each property bound from its appropriate source.
❌ Wrong: IResult without typed result
app.MapGet("/orders/{id}", (int id) => Results.Ok(/* ... */));
// OpenAPI doesn't know the response type
✅ Correct: typed result
❌ Wrong: middleware where filter would suffice
app.Use(async (ctx, next) =>
{
if (ctx.Request.Path.StartsWithSegments("/orders") && ctx.Request.Method == "POST")
{
// validation
}
await next(ctx);
});
✅ Correct: endpoint filter on the specific endpoint
Design patterns for this topic
Pattern 1 — "MapGroup per resource"
- Intent: clean organization, shared metadata.
Pattern 2 — "TypedResults everywhere"
- Intent: OpenAPI-correct, IDE-friendly.
Pattern 3 — "Endpoint filters for endpoint-specific concerns"
- Intent: validation, transformation, instrumentation.
Pattern 4 — "Built-in OpenAPI in .NET 9+"
- Intent: fewer NuGet deps, AOT-friendly schema generation.
Pattern 5 — "Minimal API extension methods for handler organization"
- Code sketch:
public static class OrdersEndpoints
{
public static IEndpointRouteBuilder MapOrders(this IEndpointRouteBuilder app)
{
var g = app.MapGroup("/orders");
g.MapGet("/{id:int}", GetById);
g.MapPost("/", Create);
return app;
}
private static async Task<Results<Ok<Order>, NotFound>> GetById(int id, IRepo r) =>
await r.GetAsync(id) is { } o ? TypedResults.Ok(o) : TypedResults.NotFound();
}
// In Program.cs:
app.MapOrders();
Pros & cons / trade-offs
| Aspect | Pros | Cons |
|---|---|---|
| Minimal APIs | Concise; AOT-first | No Razor view rendering |
| TypedResults | Strong typing | Verbose generic signature |
| MapGroup | Reuse metadata | Easy to nest deeply |
| Endpoint filters | Per-endpoint | Different mental model from MVC filters |
When to use / when to avoid
- Use Minimal APIs for new JSON APIs.
- Use TypedResults for accurate OpenAPI.
- Use MapGroup to organize.
- Avoid mixing Minimal APIs and MVC for the same feature unless transitioning.
- Avoid huge handler lambdas — extract to static methods.
Interview Q&A
Q1. When were Minimal APIs introduced? .NET 6. Reached MVC parity (validation, OpenAPI, etc.) in .NET 8/9.
Q2. What's the difference between Results.Ok and TypedResults.Ok<T>? Both return success. Results.Ok returns IResult (untyped). TypedResults.Ok<T> returns the typed Ok<T>. Prefer TypedResults for OpenAPI accuracy.
Q3. How does parameter binding work in Minimal APIs? Convention-based: route tokens → route, simple types → query, complex types → body, services → DI. Override with [FromRoute], [FromQuery], etc.
Q4. What's [AsParameters]? Flattens a type's properties into the parameter list. Each property binds from its appropriate source. Useful for query objects.
Q5. Difference between middleware and endpoint filters? Middleware runs for every request hitting the pipeline. Endpoint filters run only for the matched endpoint and have access to handler parameters.
Q6. How do you version Minimal APIs? Asp.Versioning + app.NewApiVersionSet() + MapGroup per version.
Q7. Where does OpenAPI come from in .NET 9+? The built-in Microsoft.AspNetCore.OpenApi package, replacing Swashbuckle for Minimal APIs. AOT-friendly, more accurate.
Q8. Can you stream large responses from a Minimal API? Yes: write directly to HttpContext.Response.BodyWriter or return IAsyncEnumerable<T>.
Q9. When would you choose MVC over Minimal APIs? When you need Razor view rendering, complex action conventions, or have an existing MVC codebase.
Q10. How do you do auth on a Minimal API endpoint? .RequireAuthorization() on the endpoint or group. Or [Authorize] attribute on the handler.
Gotchas / common mistakes
- ⚠️ Complex query parameter without
[AsParameters]— binds from body. - ⚠️
IResultinstead ofTypedResults— weak OpenAPI. - ⚠️ Forgetting
MapGroup— repeated metadata everywhere. - ⚠️ Lambda handlers with closures — captured locals leak per-request data.
- ⚠️ Middleware where filter belongs — wrong scope.
- ⚠️ Two
[FromBody]parameters — only one allowed by default.