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 {route} token

Query string

a simple-type parameter has no matching route token

Header

annotated [FromHeader]

Body (JSON)

a complex-type parameter (one allowed per handler)

Form / IFormFile

annotated [FromForm], or an IFormFile / IFormFileCollection

DI container

the type is a registered service

HttpContext

parameter is HttpContext, HttpRequest, ClaimsPrincipal, CancellationToken, …​

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

NET 10 adds built-in validation: register it and annotate the request type; a failing request returns a 400

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;
}