.NET & C# Development · Lesson 16 of 92

Minimal API Deep Dive — Group, Validate & Document Endpoints

The Basics — Four HTTP Verbs

C#
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:

C#
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:

C#
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) // 422

Route Groups — MapGroup()

Groups let you apply a common prefix, metadata, and filters to a set of endpoints without repeating yourself.

C#
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:

C#
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.

C#
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:

C#
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:

C#
// 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:

C#
app.MapOrderEndpoints();
app.MapProductEndpoints();
app.MapCustomerEndpoints();

OpenAPI Metadata

C#
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.