Minimal APIs
|
This section documents ASP.NET Core on .NET 10 (LTS), the current release — the minimal hosting model, the middleware pipeline, dependency injection, Minimal APIs, MVC & Razor Pages, Blazor with the current render modes, SignalR and gRPC, EF Core, ASP.NET Core Identity and policy-based authorization, output caching, rate limiting, and Native-AOT-aware building — as described by the official documentation at Microsoft Learn, which is the reference these pages are written and verified against. This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. .NET ships a major release every November and its APIs continue to evolve: the examples here target .NET 10 / C# 14. This section’s bibliography lists the reference material consulted while preparing these pages. |
Minimal APIs express an HTTP endpoint as a route plus a handler delegate, with no controller class. They are the default for new APIs and the lowest-overhead option.
Endpoints and groups
var app = builder.Build();
app.MapGet("/products", GetAllProducts); // method-group handler
app.MapGet("/products/{id:int}", (int id) => ...); // lambda handler
var products = app.MapGroup("/products")
.WithTags("Products")
.RequireAuthorization()
.AddEndpointFilter<LoggingFilter>();
products.MapPost("/", CreateProduct);
products.MapDelete("/{id:int}", DeleteProduct);
static IResult GetAllProducts(IProductStore store) => TypedResults.Ok(store.All());
MapGroup factors out a shared prefix and shared metadata (tags, auth, filters). See
Minimal APIs overview.
Parameter binding
Handler parameters are bound, by convention, from:
| Source | Bound when… |
|---|---|
Route values |
the parameter name matches a |
Query string |
a simple-type parameter has no matching route token |
Header |
annotated |
Body (JSON) |
a complex-type parameter (one allowed per handler) |
Form / |
annotated |
DI container |
the type is a registered service |
|
parameter is |
app.MapGet("/search", (string q, int page, int size, ISearchService svc, CancellationToken ct)
=> svc.QueryAsync(q, page, size, ct));
// group many query/route params into one struct
app.MapGet("/report", ([AsParameters] ReportQuery query, IReportService svc) => svc.Run(query));
public readonly record struct ReportQuery(DateOnly From, DateOnly To, string? Region);
Custom types bind from route/query by declaring static bool TryParse(string, out T), or from the body/other
sources by declaring static ValueTask<T?> BindAsync(HttpContext). Explicit attributes — [FromRoute],
[FromQuery], [FromHeader], [FromBody], [FromForm], [FromServices] — override the convention. See
Parameter binding.
Results
Return an IResult, a TypedResults value, or a plain object (serialized to JSON with a 200):
app.MapGet("/products/{id:int}", async Task<Results<Ok<Product>, NotFound>> (int id, IProductStore store) =>
{
var p = await store.FindAsync(id);
return p is null ? TypedResults.NotFound() : TypedResults.Ok(p);
});
TypedResults is strongly typed (better for testing); the Results<T1, T2, …> union declares every possible
response so OpenAPI can describe them all. Results.Problem(…) and Results.ValidationProblem(…) produce
RFC 9457 payloads. .NET 10 adds TypedResults.Stream(…) and TypedResults.ServerSentEvents(…) for
streaming responses. See
Create responses.
Endpoint filters
IEndpointFilter wraps a handler for cross-cutting concerns — validation, logging, short-circuiting — and,
unlike middleware, has access to the bound arguments.
public sealed class LoggingFilter(ILogger<LoggingFilter> logger) : IEndpointFilter
{
public async ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext ctx, EndpointFilterDelegate next)
{
logger.LogInformation("-> {Endpoint}", ctx.HttpContext.GetEndpoint()?.DisplayName);
var result = await next(ctx);
logger.LogInformation("<- done");
return result;
}
}
app.MapPost("/orders", CreateOrder).AddEndpointFilter<LoggingFilter>();
Validation
ValidationProblemDetails automatically.
builder.Services.AddValidation(); // .NET 10
public sealed record CreateProduct(
[property: Required, StringLength(100)] string Name,
[property: Range(0.01, 100000)] decimal Price);
app.MapPost("/products", (CreateProduct dto, IProductStore store) => TypedResults.Created($"/products/1"));
Before .NET 10 (or for custom rules) do it in a filter:
app.MapPost("/products", CreateProduct)
.AddEndpointFilter(async (ctx, next) =>
{
var dto = ctx.GetArgument<CreateProduct>(0);
if (string.IsNullOrWhiteSpace(dto.Name))
return Results.ValidationProblem(new Dictionary<string, string[]>
{ ["name"] = ["Name is required"] });
return await next(ctx);
});
Error handling and auth
builder.Services.AddProblemDetails(); // uniform error bodies for unhandled + status-code responses
app.MapGet("/secret", () => "shh").RequireAuthorization("AdminOnly");
app.MapGet("/public", () => "hi").AllowAnonymous();
OpenAPI metadata (WithName, WithSummary, ProducesProblem) is covered on
Web API Controllers and
OpenAPI and API Versioning.
Worked example: a Todo CRUD API
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddValidation();
builder.Services.AddOpenApi();
builder.Services.AddSingleton<TodoDb>();
var app = builder.Build();
if (app.Environment.IsDevelopment()) app.MapOpenApi();
var todos = app.MapGroup("/todos").WithTags("Todos");
todos.MapGet("/", (TodoDb db) => TypedResults.Ok(db.All()))
.WithName("ListTodos");
todos.MapGet("/{id:int}", Results<Ok<Todo>, NotFound> (int id, TodoDb db) =>
db.Find(id) is { } t ? TypedResults.Ok(t) : TypedResults.NotFound())
.WithName("GetTodo");
todos.MapPost("/", Results<Created<Todo>, ValidationProblem> (CreateTodo dto, TodoDb db) =>
{
var created = db.Add(dto.Title);
return TypedResults.Created($"/todos/{created.Id}", created);
})
.AddEndpointFilter<LoggingFilter>();
todos.MapDelete("/{id:int}", Results<NoContent, NotFound> (int id, TodoDb db) =>
db.Remove(id) ? TypedResults.NoContent() : TypedResults.NotFound());
app.Run();
public sealed record Todo(int Id, string Title, bool Done);
public sealed record CreateTodo([property: Required, StringLength(200)] string Title);
public sealed class TodoDb
{
private readonly List<Todo> _items = [];
private int _next = 1;
public IEnumerable<Todo> All() => _items;
public Todo? Find(int id) => _items.FirstOrDefault(t => t.Id == id);
public Todo Add(string title) { var t = new Todo(_next++, title, false); _items.Add(t); return t; }
public bool Remove(int id) => _items.RemoveAll(t => t.Id == id) > 0;
}