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 anIResult; useTypedResults.*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
TaskorValueTaskfrom the lambda makes the endpoint asynchronous without extra plumbing. CancellationTokenis 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();
});| Concern | Minimal API approach | Controller approach |
|---|---|---|
| Routing | MapGet / MapPost lambdas | Attribute routes on action methods |
| Binding | Implicit by parameter type and name | Model binding plus [FromBody] etc. |
| Cross-cutting | Endpoint filters | Action filters and resource filters |
| Validation | Manual or via a filter | Model state with validation attributes |
| OpenAPI | Adds metadata from TypedResults | Describes 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
ValidationProblemrather 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.Related
Controllers, dependency injection and middleware Configuration and logging
Last refreshed 2026-09-18.