.NET & C# Development · Lesson 16 of 92
Minimal API Deep Dive — Group, Validate & Document Endpoints
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.