Minimal APIs

Build an HTTP API with route handlers, typed results and endpoint groups, and know when the minimal model is enough.

The whole application is one file

A minimal API is a WebApplication built by a WebApplicationBuilder. Services are registered on builder.Services, endpoints are mapped on the app, and app.Run() starts the server. There is no startup class and no controller discovery unless you ask for them.

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddSingleton<IClock, SystemClock>();
builder.Services.AddProblemDetails();

var app = builder.Build();

app.UseExceptionHandler();
app.UseStatusCodePages();

app.MapGet("/health", () => Results.Ok(new { status = "ok" }));

app.MapGet("/orders/{id:int}", async (int id, IOrderStore store, CancellationToken ct) =>
{
    var order = await store.FindAsync(id, ct);
    return order is null ? Results.NotFound() : Results.Ok(order);
});

app.MapPost("/orders", (CreateOrder request, IOrderStore store) =>
{
    if (string.IsNullOrWhiteSpace(request.Customer))
        return Results.ValidationProblem(new Dictionary<string, string[]>
        {
            ["customer"] = new[] { "Customer is required." }
        });

    var created = store.Add(request);
    return Results.Created($"/orders/{created.Id}", created);
});

app.Run();
  • Results.* returns an IResult; use TypedResults.* when you want the concrete type for OpenAPI metadata and compile-time checking.
  • Handler parameters are bound automatically: route values, query string, JSON body, and any service registered in the container.
  • Returning Task or ValueTask from the lambda makes the endpoint asynchronous without extra plumbing.
  • CancellationToken is bound to the request aborted token — always pass it to database and HTTP calls.

Grouping and filters

Endpoint groups attach a route prefix, tags and filters to many endpoints at once, which is how a minimal API stays readable as it grows past a handful of routes.

var orders = app.MapGroup("/api/orders")
                 .WithTags("Orders")
                 .RequireAuthorization()
                 .AddEndpointFilter<LoggingFilter>();

orders.MapGet("/",        (IOrderStore s) => TypedResults.Ok(s.All()));
orders.MapGet("/{id:int}", async (int id, IOrderStore s, CancellationToken ct) =>
{
    var order = await s.FindAsync(id, ct);
    return order is null
        ? (IResult)TypedResults.NotFound()
        : TypedResults.Ok(order);
});
orders.MapDelete("/{id:int}", async (int id, IOrderStore s, CancellationToken ct) =>
{
    await s.DeleteAsync(id, ct);
    return TypedResults.NoContent();
});
ConcernMinimal API approachController approach
RoutingMapGet / MapPost lambdasAttribute routes on action methods
BindingImplicit by parameter type and nameModel binding plus [FromBody] etc.
Cross-cuttingEndpoint filtersAction filters and resource filters
ValidationManual or via a filterModel state with validation attributes
OpenAPIAdds metadata from TypedResultsDescribes actions and return types
💡
Both models run on the same routing, DI and middleware foundation. Pick minimal APIs for focused services and controllers when you need filters, conventions or a large surface area — and mix them freely in one app.

Validation and errors

public sealed record CreateOrder(string Customer, List<Line> Lines);

public sealed class CreateOrderValidator : AbstractValidator<CreateOrder>
{
    public CreateOrderValidator()
    {
        RuleFor(x => x.Customer).NotEmpty().MaximumLength(120);
        RuleFor(x => x.Lines).NotEmpty();
        RuleForEach(x => x.Lines).ChildRules(line =>
            line.RuleFor(l => l.Quantity).GreaterThan(0));
    }
}

public sealed class ValidationFilter<T> : IEndpointFilter where T : class
{
    public async ValueTask<object?> InvokeAsync(
        EndpointFilterInvocationContext ctx, EndpointFilterDelegate next)
    {
        var validator = ctx.HttpContext.RequestServices.GetService<IValidator<T>>();
        if (validator is null) return await next(ctx);

        var model = ctx.Arguments.OfType<T>().FirstOrDefault();
        if (model is null) return await next(ctx);

        var result = await validator.ValidateAsync(model);
        return result.IsValid
            ? await next(ctx)
            : TypedResults.ValidationProblem(result.ToDictionary());
    }
}
  • Return ValidationProblem rather than a bare 400 so the client gets field-level messages in a stable shape.
  • Register the filter per group with AddEndpointFilter, not per endpoint — one line beats repetition that drifts.
  • Use Problem Details (AddProblemDetails) for unhandled errors so every failure shares one response contract.
  • Never put a stack trace or an exception message in a production error response; log it and return an opaque identifier.

FAQ

Minimal APIs or controllers — which should a new project use?
Minimal APIs for small to medium HTTP services, especially when the app is mostly endpoints and JSON. Controllers once you need rich filter pipelines, conventions or a very large surface area. The two compose, so the choice is per-area, not per-repository.
How do I add Swagger or OpenAPI?
Register the OpenAPI services and map the endpoint in Development only. Metadata comes from TypedResults, route constraints and .WithTags/.Produces calls, so accuracy is a consequence of how you wrote the handler.

Controllers, dependency injection and middleware Configuration and logging

Last refreshed 2026-09-18.