Minimal API Deep Dive — Group, Validate & Document Endpoints
Go beyond MapGet. Learn route groups, typed results, endpoint filters for validation, IEndpointFilter, and how to organize a real API with extension methods and full OpenAPI metadata.
The Basics — Four HTTP Verbs
var app = WebApplication.Create(args);
app.MapGet("/orders", async (AppDbContext db, CancellationToken ct) =>
await db.Orders.ToListAsync(ct));
app.MapGet("/orders/{id}", async (int id, AppDbContext db, CancellationToken ct) =>
{
var order = await db.Orders.FindAsync([id], ct);
return order is null ? Results.NotFound() : Results.Ok(order);
});
app.MapPost("/orders", async (CreateOrderRequest req, AppDbContext db, CancellationToken ct) =>
{
var order = new Order { CustomerId = req.CustomerId, Total = req.Total };
db.Orders.Add(order);
await db.SaveChangesAsync(ct);
return Results.Created($"/orders/{order.Id}", order);
});
app.MapPut("/orders/{id}", async (int id, UpdateOrderRequest req, AppDbContext db, CancellationToken ct) =>
{
var order = await db.Orders.FindAsync([id], ct);
if (order is null) return Results.NotFound();
order.Total = req.Total;
await db.SaveChangesAsync(ct);
return Results.Ok(order);
});
app.MapDelete("/orders/{id}", async (int id, AppDbContext db, CancellationToken ct) =>
{
var rows = await db.Orders.Where(o => o.Id == id).ExecuteDeleteAsync(ct);
return rows == 0 ? Results.NotFound() : Results.NoContent();
});
app.Run();Parameter binding is automatic: route values, query string, and JSON body are all resolved by position and type.
Typed Results — Stop Returning IResult
TypedResults (note the 'd') gives you compile-time type information that OpenAPI can use:
app.MapGet("/orders/{id}", async Task<Results<Ok<Order>, NotFound>>(
int id, AppDbContext db, CancellationToken ct) =>
{
var order = await db.Orders.FindAsync([id], ct);
return order is null
? TypedResults.NotFound()
: TypedResults.Ok(order);
});When the return type is Results<Ok<Order>, NotFound>, the OpenAPI generator knows the endpoint can return either 200 with an Order body or a 404.
Common typed result types:
TypedResults.Ok(value) // 200
TypedResults.Created(uri, value) // 201
TypedResults.NoContent() // 204
TypedResults.NotFound() // 404
TypedResults.BadRequest(detail) // 400
TypedResults.Conflict() // 409
TypedResults.UnprocessableEntity(validationProblem) // 422Route Groups — MapGroup()
Groups let you apply a common prefix, metadata, and filters to a set of endpoints without repeating yourself.
var orders = app.MapGroup("/api/orders")
.RequireAuthorization()
.WithTags("Orders")
.WithOpenApi();
orders.MapGet("/", GetAllOrders);
orders.MapGet("/{id}", GetOrderById);
orders.MapPost("/", CreateOrder);
orders.MapPut("/{id}", UpdateOrder);
orders.MapDelete("/{id}", DeleteOrder);Groups are composable — nest them:
var v1 = app.MapGroup("/api/v1");
var v1Orders = v1.MapGroup("/orders").WithTags("Orders v1");
var v1Products = v1.MapGroup("/products").WithTags("Products v1");IEndpointFilter — Validation Without Repeating Yourself
Endpoint filters are the Minimal API equivalent of action filters. They wrap endpoint execution.
public class ValidationFilter<TRequest> : IEndpointFilter
{
private readonly IValidator<TRequest> _validator;
public ValidationFilter(IValidator<TRequest> validator)
{
_validator = validator;
}
public async ValueTask<object?> InvokeAsync(
EndpointFilterInvocationContext context,
EndpointFilterDelegate next)
{
// Find the request argument by type
var request = context.Arguments
.OfType<TRequest>()
.FirstOrDefault();
if (request is not null)
{
var result = await _validator.ValidateAsync(request);
if (!result.IsValid)
{
return TypedResults.ValidationProblem(
result.ToDictionary());
}
}
return await next(context);
}
}Apply it per-endpoint or per-group:
orders.MapPost("/", CreateOrder)
.AddEndpointFilter<ValidationFilter<CreateOrderRequest>>();
// Or apply to the whole group
var orders = app.MapGroup("/api/orders")
.AddEndpointFilter<ValidationFilter<CreateOrderRequest>>();Organizing Endpoints With Extension Methods
Don't dump 500 lines into Program.cs. Split endpoints into feature modules:
// Features/Orders/OrderEndpoints.cs
public static class OrderEndpoints
{
public static IEndpointRouteBuilder MapOrderEndpoints(
this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/orders")
.RequireAuthorization()
.WithTags("Orders")
.WithOpenApi();
group.MapGet("/", GetAll)
.WithName("GetAllOrders")
.WithSummary("List all orders");
group.MapGet("/{id:int}", GetById)
.WithName("GetOrderById")
.WithSummary("Get an order by ID")
.Produces<Order>(200)
.ProducesProblem(404);
group.MapPost("/", Create)
.WithName("CreateOrder")
.WithSummary("Create a new order")
.AddEndpointFilter<ValidationFilter<CreateOrderRequest>>();
return app;
}
private static async Task<Ok<List<Order>>> GetAll(
AppDbContext db, CancellationToken ct) =>
TypedResults.Ok(await db.Orders.ToListAsync(ct));
private static async Task<Results<Ok<Order>, NotFound>> GetById(
int id, AppDbContext db, CancellationToken ct)
{
var order = await db.Orders.FindAsync([id], ct);
return order is null ? TypedResults.NotFound() : TypedResults.Ok(order);
}
private static async Task<Created<Order>> Create(
CreateOrderRequest req, AppDbContext db, CancellationToken ct)
{
var order = new Order { CustomerId = req.CustomerId, Total = req.Total };
db.Orders.Add(order);
await db.SaveChangesAsync(ct);
return TypedResults.Created($"/api/orders/{order.Id}", order);
}
}In Program.cs:
app.MapOrderEndpoints();
app.MapProductEndpoints();
app.MapCustomerEndpoints();OpenAPI Metadata
group.MapPost("/", Create)
.WithName("CreateOrder")
.WithSummary("Create a new order")
.WithDescription("Creates a new order for the specified customer.")
.WithTags("Orders")
.Produces<Order>(StatusCodes.Status201Created)
.Produces<ProblemDetails>(StatusCodes.Status400BadRequest)
.Produces<ProblemDetails>(StatusCodes.Status401Unauthorized)
.RequireAuthorization();Minimal API vs Controllers — When to Use Each
| | Minimal API | Controllers |
|---|---|---|
| Verbosity | Less code for simple endpoints | More structure |
| Filters | IEndpointFilter | IActionFilter, IExceptionFilter |
| Model validation | Manual or via filter | Automatic with [ApiController] |
| Routing | Inline | Attribute routing |
| Best for | Microservices, simple CRUD APIs | Large apps with complex filter chains |
Minimal APIs are not "controllers lite". For small-to-medium APIs they are the right default. For complex apps where you need rich filter pipelines, controllers still win.
Enjoyed this article?
Explore the Backend Systems learning path for more.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.