Minimal APIs in ASP.NET Core let you build HTTP APIs by mapping routes directly to C# delegates, without controllers, attributes or base classes. Since their introduction in .NET 6 they have grown into a complete API framework, with typed results, route groups, filters, built-in validation in .NET 10, first-party OpenAPI generation and Native AOT support. This guide is for developers building production APIs. It covers how Minimal APIs work under the hood, how to bind, validate, document, secure, organize and test them, and when controllers are still the better choice.
What Are Minimal APIs?#
A Minimal API endpoint is a route template plus a route handler: a lambda, local function or method whose parameters describe the inputs and whose return value describes the response. There is no controller to instantiate and no action selection step. Handlers plug straight into ASP.NET Core endpoint routing, the same infrastructure that MVC, Razor Pages, Blazor and SignalR use, so middleware, authorization and rate limiting behave identically.
The "minimal" refers to ceremony, not capability. The ASP.NET Core webapi template has used Minimal APIs by default since .NET 8 (--use-controllers switches back), and the webapiaot template produces a Native AOT-ready Minimal API. Microsoft's own guidance recommends Minimal APIs for new API projects, while controllers remain fully supported.
How Minimal APIs Work#
When you call app.MapGet("/orders/{id}", handler), ASP.NET Core inspects the handler's signature and generates a RequestDelegate that does four jobs: it binds each parameter from the request, runs any endpoint filters, invokes your code and writes the result. It also infers endpoint metadata from the signature, such as which parameter comes from the body and which response types are possible. OpenAPI generation and authorization read that metadata later.
That code generation happens in one of two ways:
- At runtime (default). The
RequestDelegateFactorybuilds the delegate with expression trees when the app starts. - At compile time. The Request Delegate Generator (RDG), a source generator, emits the same logic as plain C#. It turns on automatically when you enable trimming or
PublishAot, and you can opt in with<EnableRequestDelegateGenerator>true</EnableRequestDelegateGenerator>to reduce startup work.
Because binding is compiled per endpoint rather than discovered through a general-purpose model binder, Minimal APIs have very little per-request overhead.
Getting Started with Minimal APIs#
Create a project with dotnet new webapi -n Todo.Api. The template references Microsoft.AspNetCore.OpenApi and maps a sample endpoint. A small but realistic API looks like this:
using System.Collections.Concurrent;
using Microsoft.AspNetCore.Http.HttpResults;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddOpenApi();
builder.Services.AddSingleton<TodoStore>();
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.MapOpenApi(); // Serves /openapi/v1.json
}
app.MapGet("/todos/{id:int}", Results<Ok<Todo>, NotFound> (int id, TodoStore store) =>
store.Find(id) is { } todo ? TypedResults.Ok(todo) : TypedResults.NotFound());
app.MapPost("/todos", (CreateTodo input, TodoStore store) =>
{
var todo = store.Add(input.Title);
return TypedResults.Created($"/todos/{todo.Id}", todo);
});
app.Run();
public sealed record Todo(int Id, string Title, bool IsDone = false);
public sealed record CreateTodo(string Title);
public sealed class TodoStore
{
private readonly ConcurrentDictionary<int, Todo> _items = new();
private int _nextId;
public Todo? Find(int id) => _items.GetValueOrDefault(id);
public Todo Add(string title)
{
var todo = new Todo(Interlocked.Increment(ref _nextId), title);
_items[todo.Id] = todo;
return todo;
}
}MapGet, MapPost, MapPut, MapPatch and MapDelete cover the common verbs, and MapMethods handles any other method. A handler can be a lambda, a local function, a static method or an instance method. Named methods are easier to unit test and, since .NET 10, can carry XML doc comments into the generated OpenAPI document.
Parameter Binding in Minimal APIs#
Parameter binding converts request data into typed handler arguments. ASP.NET Core infers the source from the parameter's type and name, and you can override it with [FromRoute], [FromQuery], [FromHeader], [FromBody], [FromForm], [FromServices] or [FromKeyedServices].
using System.Security.Claims;
using Microsoft.AspNetCore.Mvc;
app.MapGet("/stores/{storeId:int}/products", async (
int storeId, // Route value
string? search, // Query string: ?search=
[FromHeader(Name = "X-Tenant-Id")] string tenant, // Header
ProductCatalog catalog, // Service from DI
ClaimsPrincipal user, // Special type: HttpContext.User
CancellationToken ct) => // Special type: RequestAborted
TypedResults.Ok(await catalog.SearchAsync(tenant, storeId, search, ct)));The inference rules are worth memorizing:
- Explicit
From*attributes and[AsParameters]win. - Special types bind automatically:
HttpContext,HttpRequest,HttpResponse,ClaimsPrincipal,CancellationToken,IFormFile,IFormFileCollection,IFormCollection,StreamandPipeReader. - Types with a static
BindAsyncmethod, or implementingIBindableFromHttpContext<T>, bind themselves. - Strings and types with a static
TryParsebind from the route if the name appears in the template, otherwise from the query string. - Types registered in dependency injection come from the container.
- Everything else is read from the JSON body.
GET, HEAD, OPTIONS and DELETE never bind from the body implicitly. Parameters are required unless they are nullable or have a default value. When binding fails, the framework responds without calling your handler: 400 for unparsable values or malformed JSON, 415 for a body that isn't application/json, and 500 if a custom BindAsync throws.
Grouping Parameters with AsParameters#
Long parameter lists hurt readability. [AsParameters] binds the properties or constructor parameters of a type as if they were separate handler parameters, so every property keeps its own binding source:
app.MapGet("/orders", async ([AsParameters] OrderSearch search, CancellationToken ct) =>
TypedResults.Ok(await search.Orders.SearchAsync(search.Status, search.Page,
search.PageSize, ct)));
public readonly record struct OrderSearch(
IOrderService Orders, // From DI
OrderStatus? Status, // From ?status=
int Page = 1, // From ?page=
[FromQuery(Name = "size")] int PageSize = 20);[AsParameters] is flat binding, not recursive model binding, and a struct avoids an allocation per request.
TypedResults and Results in Minimal APIs#
A handler can return a string (written as text/plain), any other object (serialized as JSON), or an IResult that controls the response completely. Two factories create IResult values: Results, whose methods all return IResult, and TypedResults, whose methods return concrete types such as Ok<T>, NotFound and Created<T>. Prefer TypedResults, because the concrete types describe themselves to OpenAPI and are easy to assert in unit tests.
When a handler returns different outcomes, declare them with the Results<T1, T2, ...> union type, which supports up to six result types. The compiler then rejects any return value you didn't declare, and the OpenAPI document lists every possible response without extra Produces calls:
app.MapPut("/todos/{id:int}", Results<NoContent, NotFound, ValidationProblem> (
int id, UpdateTodo input, TodoStore store) =>
{
if (string.IsNullOrWhiteSpace(input.Title))
{
return TypedResults.ValidationProblem(new Dictionary<string, string[]>
{
[nameof(input.Title)] = ["Title is required."]
});
}
return store.TryUpdate(id, input.Title) ? TypedResults.NoContent() : TypedResults.NotFound();
});.NET 10 added TypedResults.ServerSentEvents, which streams an IAsyncEnumerable<T> to the browser as Server-Sent Events for one-way push scenarios.
Route Groups and Endpoint Filters#
MapGroup creates a RouteGroupBuilder that shares a route prefix and conventions, such as authorization, tags, CORS, rate limiting and filters, across every endpoint mapped on it. Groups can nest, and metadata accumulates from outer to inner groups.
Endpoint filters are the Minimal API counterpart of MVC action filters. A filter receives an EndpointFilterInvocationContext with the HttpContext and the already-bound arguments. It can inspect or replace arguments, short-circuit by returning a result, or post-process the handler's result. Code before next runs in registration order, and code after next runs in reverse order.
public sealed class AuditFilter(ILogger<AuditFilter> logger) : IEndpointFilter
{
public async ValueTask<object?> InvokeAsync(
EndpointFilterInvocationContext context, EndpointFilterDelegate next)
{
var result = await next(context); // Later filters, then the handler
logger.LogInformation("{User} called {Endpoint}",
context.HttpContext.User.Identity?.Name ?? "anonymous",
context.HttpContext.GetEndpoint()?.DisplayName);
return result;
}
}
var todos = app.MapGroup("/todos")
.WithTags("Todos")
.AddEndpointFilter<AuditFilter>(); // Applies to every endpoint in the group
todos.MapPut("/{id:int}", UpdateTodoHandler)
.AddEndpointFilter(async (context, next) =>
{
var id = context.GetArgument<int>(0);
var body = context.GetArgument<UpdateTodo>(1);
return id != body.Id
? TypedResults.Problem("Route id and body id differ.", statusCode: 400)
: await next(context);
});Filters can take constructor dependencies from dependency injection, but they aren't resolved from the container themselves. For signature-dependent behavior, AddEndpointFilterFactory lets you inspect the handler's MethodInfo once at startup. Since .NET 11 Preview 4, also backported to 10.0.8, filters run even when parameter binding fails, so a filter can see the 400 status code and substitute its own response body.
Validation in Minimal APIs with .NET 10#
Before .NET 10, Minimal APIs had no built-in validation, so teams hand-wrote filters or used FluentValidation. .NET 10 adds first-class support through Microsoft.Extensions.Validation, which ships in the ASP.NET Core shared framework. Call AddValidation() and the framework validates query, header and body parameters against System.ComponentModel.DataAnnotations attributes before your handler runs:
using System.ComponentModel.DataAnnotations;
builder.Services.AddProblemDetails();
builder.Services.AddValidation(); // Source generator discovers handler parameter types
app.MapPost("/customers", (CreateCustomer request) =>
TypedResults.Created($"/customers/{Guid.NewGuid()}", request));
app.MapGet("/customers", ([Range(1, 100)] int pageSize = 20) => TypedResults.Ok(pageSize));
public sealed record CreateCustomer(
[Required, StringLength(100)] string Name,
[Required, EmailAddress] string Email,
[Range(18, 130)] int Age) : IValidatableObject
{
public IEnumerable<ValidationResult> Validate(ValidationContext context)
{
if (Name.Equals(Email, StringComparison.OrdinalIgnoreCase))
{
yield return new ValidationResult("Name must differ from email.", [nameof(Name)]);
}
}
}Invalid requests get a 400 response with a validation problem details body, and the handler never runs. A few rules explain most surprises:
- Discovery is compile-time. A source generator finds the types used by handlers, including nested objects and collections, but only in the assembly that calls
AddValidation. Call it from each assembly that defines endpoints. - Order is fixed. Property attributes run first. Type-level attributes and
IValidatableObject.Validaterun only if the properties are valid. - Opt out per endpoint with
.DisableValidation(), or skip a member with[SkipValidation], which is marked experimental in .NET 10 (diagnosticASP0029). - Customize the response by registering an
IProblemDetailsServiceimplementation or configuringAddProblemDetails.
.NET 11 extends the same pipeline with asynchronous rules through AsyncValidationAttribute and IAsyncValidatableObject, which suit database uniqueness checks, and with built-in localization of validation messages. In the .NET 11 previews, [ValidatableType] and [SkipValidation] also stopped being experimental, so the ASP0029 suppression is no longer needed.
Organizing Large Minimal API Apps#
A single Program.cs with 80 lambdas doesn't scale. The pattern that works is one static class per feature, with an extension method that maps a route group and named handler methods:
namespace Shop.Api.Features.Orders;
public static class OrderEndpoints
{
public static IEndpointRouteBuilder MapOrderEndpoints(this IEndpointRouteBuilder routes)
{
var group = routes.MapGroup("/api/orders")
.WithTags("Orders")
.RequireAuthorization();
group.MapGet("/{id:guid}", GetById).WithName("GetOrder");
group.MapPost("/", Create);
return routes;
}
public static async Task<Results<Ok<OrderDto>, NotFound>> GetById(
Guid id, IOrderService orders, CancellationToken ct) =>
await orders.FindAsync(id, ct) is { } order
? TypedResults.Ok(order)
: TypedResults.NotFound();
public static async Task<CreatedAtRoute<OrderDto>> Create(
CreateOrder command, IOrderService orders, CancellationToken ct)
{
var order = await orders.CreateAsync(command, ct);
return TypedResults.CreatedAtRoute(order, "GetOrder", new { id = order.Id });
}
}Program.cs then reads like a table of contents: app.MapOrderEndpoints(); app.MapCustomerEndpoints();. Keep handlers thin and push business logic into services, or into command handlers if you use CQRS. Reflection-based helpers that scan assemblies for endpoint classes are popular, but explicit extension methods stay trimming-safe and easier to navigate.
Native AOT Support in Minimal APIs#
Minimal APIs are the ASP.NET Core API model that supports Native AOT; MVC controllers and Blazor Server don't. AOT gives you a single native executable with fast startup and a small memory footprint, which suits containers and scale-to-zero platforms. Start from dotnet new webapiaot, or add <PublishAot>true</PublishAot> to the project file, and then satisfy two requirements:
using System.Text.Json.Serialization;
var builder = WebApplication.CreateSlimBuilder(args);
// Every type crossing the HTTP boundary needs source-generated JSON metadata.
builder.Services.ConfigureHttpJsonOptions(options =>
options.SerializerOptions.TypeInfoResolverChain.Insert(0, AppJsonContext.Default));
var app = builder.Build();
app.MapGet("/todos", () => TypedResults.Ok(new[] { new Todo(1, "Ship it") }));
app.Run();
public sealed record Todo(int Id, string Title);
[JsonSerializable(typeof(Todo[]))]
internal partial class AppJsonContext : JsonSerializerContext
{
}First, JSON must use the System.Text.Json source generator, because reflection-based serialization is unavailable. Second, the RDG replaces runtime code generation, so pass lambdas or method groups directly to the Map* calls where the generator can see them. Treat every AOT or trimming warning at publish time as a bug, because a warning-free publish is the signal that the native app behaves like the JIT version.
Testing Minimal APIs#
Test at two levels. Handlers written as static methods that return TypedResults are plain functions, so unit tests call them directly and assert on concrete result types. Integration tests host the whole app in memory with WebApplicationFactory<Program> from Microsoft.AspNetCore.Mvc.Testing, which exercises routing, binding, validation, filters and serialization together:
public class OrderEndpointTests(WebApplicationFactory<Program> factory)
: IClassFixture<WebApplicationFactory<Program>>
{
[Fact]
public async Task GetById_returns_NotFound_for_unknown_order()
{
var orders = new InMemoryOrderService(); // Test double
var result = await OrderEndpoints.GetById(Guid.NewGuid(), orders, default);
Assert.IsType<NotFound>(result.Result);
}
[Fact]
public async Task Post_customer_with_invalid_body_returns_400()
{
var client = factory.CreateClient();
var response = await client.PostAsJsonAsync("/customers",
new { Name = "", Email = "not-an-email", Age = 12 });
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
var problem = await response.Content.ReadFromJsonAsync<HttpValidationProblemDetails>();
Assert.NotEmpty(problem!.Errors);
}
}Before .NET 10 you had to add public partial class Program { } so tests could reference the top-level-statement entry point. .NET 10 generates that declaration for you. Use WithWebHostBuilder and ConfigureTestServices to swap databases or external clients. The integration testing guide covers Testcontainers and authentication in tests.
Best Practices#
- Return
TypedResultsandResults<...>so responses are compile-time checked and documented automatically. - Pass
CancellationTokento every I/O call. It binds toHttpContext.RequestAbortedat no cost. - Group by feature with
MapGroupand extension methods, and apply authorization at the group level so new endpoints are secure by default. - Turn on
AddValidation()in .NET 10 instead of hand-written validation filters, and reserve filters for cross-cutting concerns. - Use named handler methods for anything non-trivial. They are testable and carry XML documentation into OpenAPI.
- Keep DTOs separate from entities so the API contract doesn't change when the database schema does.
- Publish with trimming or AOT analysis in CI if you plan to ship AOT, so incompatible dependencies surface early.
Common Pitfalls#
- Accidental body binding. A complex type that isn't registered in dependency injection binds from the JSON body. If you forget to register a service, requests fail with 400 or 415 instead of a startup error.
- Mixed
TypedResultswithout a union. ReturningTypedResults.OkandTypedResults.NotFoundfrom one lambda doesn't compile. DeclareResults<Ok<T>, NotFound>as the return type. - Validation silently off. Without
AddValidation(), or with endpoints in another assembly, DataAnnotations attributes are ignored and no error is logged. - Route constraints used as validation.
{id:int}returns 404 forabc. Use validation when clients need a 400 with details. - Blocking calls in handlers.
.Resultand.Wait()starve the thread pool. Keep handlers asynchronous end to end. - Leaking entities. Returning EF Core entities serializes navigation properties and can cause cycles or over-posting.
Minimal APIs vs Controllers#
| Aspect | Minimal APIs | Controllers |
|---|---|---|
| Style | Route-to-delegate; features composed explicitly | Classes, attributes and conventions |
| Performance | Lower overhead; compile-time delegates with RDG | Slightly more per-request work |
| Native AOT | Supported | Not supported |
| Validation | AddValidation() in .NET 10 | [ApiController] and ModelState |
| Filters | Endpoint filters | Action, result, exception and resource filters |
| Content negotiation | JSON by default | Pluggable input and output formatters, such as XML |
| Best fit | New APIs, microservices, serverless, AOT | Large existing MVC codebases and formatter-heavy APIs |
Both models share routing, dependency injection, middleware, authentication and OpenAPI, so you can mix them in one app. The Minimal APIs vs controllers interview guide explores the trade-offs in more depth.
Frequently Asked Questions#
Are Minimal APIs ready for large production systems?#
Yes. With route groups, typed results, filters, built-in validation and OpenAPI, Minimal APIs cover what most APIs need, and they run on the same hosting and middleware stack as MVC. The main work is organizational: structure endpoints by feature so the codebase stays navigable.
Do I need FluentValidation with .NET 10?#
Not for typical DataAnnotations-style rules. AddValidation() handles attributes, nested objects, collections and IValidatableObject. FluentValidation remains a reasonable choice if you already use it or prefer its fluent rule syntax.
How do I return different status codes from one handler?#
Declare the return type as Results<T1, T2, ...>, for example Results<Ok<Order>, NotFound, Conflict>, and return the matching TypedResults values. The union supports up to six result types and documents each response in OpenAPI.
Can I use Minimal APIs and controllers in the same project?#
Yes. Call AddControllers() and MapControllers() alongside your Map* calls. Both register endpoints in the same routing table and share middleware, so many teams migrate incrementally.
Why does my handler return 415 Unsupported Media Type?#
A parameter is being bound from the request body, and the request doesn't have a JSON content type. Usually the parameter should come from dependency injection or the query string. Register the service, or add the correct From* attribute.
Summary#
- Minimal APIs map routes to delegates. ASP.NET Core generates optimized binding and response code at startup, or at compile time with the RDG.
- Binding sources are inferred from types and names.
[AsParameters]keeps signatures readable. TypedResultswithResults<...>gives compile-time-checked responses and accurate OpenAPI output.- .NET 10 adds built-in validation through
AddValidation(), and .NET 11 adds async validation and OpenAPI 3.2 by default. - Route groups, feature extension methods and named handlers keep large APIs organized, testable and AOT-friendly.