Minimal APIs are ASP.NET Core without the controller ceremony: you map a URL and an HTTP method to a function, and the framework binds the parameters, serialises the result and runs the same middleware, authentication and dependency injection as any other ASP.NET Core app. For an API of any size up to a few hundred endpoints they are the default choice in current .NET. The things that make them production-ready - route groups to organise endpoints, TypedResults so the return types document themselves, built-in validation (new in .NET 10), problem details for errors and a generated OpenAPI document - are each a line or two, and this post shows all of them.
The examples target .NET 10, the current long-term support release. Where a feature arrived later than .NET 8, the text says so.
A minimal API is a whole web app#
The smallest useful one is four lines:
var builder = WebApplication.CreateBuilder(args);var app = builder.Build();app.MapGet("/healthz", () => Results.Ok(new { ok = true }));app.Run();That is Kestrel, the full configuration system, logging, dependency injection and the middleware pipeline - the same host an MVC app runs on, without the parts that discover controllers by reflection. There is no performance or capability penalty for starting this way: you can add authentication, rate limiting, EF Core, background services and even controllers later, to the same app.
The endpoint handlers are ordinary delegates. A lambda is fine for one line; for anything longer, point the route at a static method so the handler can be read, tested and found in a stack trace:
app.MapGet("/orders/{id:int}", OrderEndpoints.GetById);static class OrderEndpoints{ public static async Task<Results<Ok<OrderDto>, NotFound>> GetById( int id, ShopDb db, CancellationToken ct) { var order = await db.Orders.AsNoTracking() .Where(o => o.Id == id) .Select(o => new OrderDto(o.Id, o.Total, o.Status)) .FirstOrDefaultAsync(ct); return order is null ? TypedResults.NotFound() : TypedResults.Ok(order); }}JSON is handled by System.Text.Json with web defaults: property names are written in camelCase, matching on input is case-insensitive, and numbers can be read from strings. Two settings are worth deciding on the first day rather than after clients depend on the output. Enums are serialised as numbers unless you add a converter, which makes responses hard to read and fragile when someone reorders the enum. And null properties are written out unless you tell the serialiser to skip them. Both live in one place:
builder.Services.ConfigureHttpJsonOptions(o =>{ o.SerializerOptions.Converters.Add(new JsonStringEnumConverter()); o.SerializerOptions.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull;});These options apply to minimal API endpoints only. If the same app also has controllers, they are configured separately through AddControllers().AddJsonOptions(...), and forgetting that is how one app ends up with two JSON styles.
Routes and parameter binding#
Each handler parameter is filled from somewhere, and the framework infers where from the parameter's type and name. Explicit attributes override the inference.
| Source | Inferred when | Explicit attribute |
|---|---|---|
| Route value | Name matches a {segment} | [FromRoute] |
| Query string | Simple type, no route match | [FromQuery] |
| Header | Never inferred | [FromHeader(Name = "X-Tenant")] |
| JSON body | Complex type on POST, PUT, PATCH | [FromBody] |
| Form | Never inferred | [FromForm] (.NET 8 and later) |
| Services | Type is registered in DI | [FromServices] |
| Special types | HttpContext, CancellationToken, ClaimsPrincipal... | none needed |
Route constraints such as {id:int}, {slug:alpha} and {page:int:min(1)} reject non-matching URLs with a 404 before your code runs. They are for routing, not validation: a constraint failure is "this URL does not exist", which is a different answer from "your input is wrong".
Always accept a CancellationToken in handlers that do I/O and pass it on. When the client disconnects, the token is cancelled and the database query stops instead of finishing for nobody.
Nullability is part of the contract. A parameter declared as int page is required: a request without ?page= gets a 400 before the handler runs. Declare it int? page or give it a default value, int page = 1, and it becomes optional. The same applies to bodies - a non-nullable body parameter rejects an empty request. In Development the 400 response explains which parameter failed to bind; in Production it is deliberately terse, so log binding failures if you need to see them.
When a handler takes many parameters, group them into a type with [AsParameters]:
public record OrderQuery(int Page = 1, int PageSize = 20, string? Status = null);app.MapGet("/orders", ([AsParameters] OrderQuery q, ShopDb db) => /* ... */);Since .NET 8, endpoints that bind forms or IFormFile uploads are marked as requiring antiforgery validation. For an API called by other programs rather than browsers, call .DisableAntiforgery() on that endpoint; for a browser form, register antiforgery and send the token with the form.
Returning results: Results and TypedResults#
A handler can return a plain object (serialised as JSON with status 200), a string (written as text), or an IResult. Two static classes create results:
Results.Ok(x),Results.NotFound()and friends returnIResult. Flexible, but the framework cannot see from the signature what the endpoint returns.TypedResults.Ok(x)and friends return concrete types such asOk<OrderDto>. Combined with a union return type,Results<Ok<OrderDto>, NotFound, ValidationProblem>, the signature itself states every possible response, and the OpenAPI generator documents them without extra attributes.
Prefer TypedResults with a union return type. It is checked by the compiler - returning a status not in the union is a compile error - and it makes the API description accurate for free. It also makes handlers easy to unit test, because the result is a typed object you can assert on.
Route groups and organising a real project#
MapGroup gives a set of endpoints a shared prefix and shared configuration:
var api = app.MapGroup("/api");api.MapGroup("/orders") .WithTags("Orders") .RequireAuthorization() .MapOrderEndpoints();api.MapGroup("/products") .WithTags("Products") .MapProductEndpoints();public static class OrderEndpointsExtensions{ public static RouteGroupBuilder MapOrderEndpoints(this RouteGroupBuilder group) { group.MapGet("/", OrderEndpoints.List); group.MapGet("/{id:int}", OrderEndpoints.GetById); group.MapPost("/", OrderEndpoints.Create); group.MapDelete("/{id:int}", OrderEndpoints.Delete) .RequireAuthorization("admin"); return group; }}This is the structure that keeps a minimal API readable at fifty endpoints: one file per feature, an extension method that maps that feature's routes, and Program.cs reduced to a table of contents. Anything applied to a group - authorisation, rate limiting, filters, OpenAPI tags - applies to every endpoint in it, and nested groups inherit from their parents.
Test endpoints through HTTP rather than by calling handlers alone, at least for the important paths. The Microsoft.AspNetCore.Mvc.Testing package starts the whole app in memory with WebApplicationFactory<Program>, so a test exercises routing, binding, validation, filters and serialisation exactly as a client would, without opening a port. Swap the database for a test instance in the factory's ConfigureWebHost, and a few dozen such tests catch most of the regressions a refactor introduces. Handlers written as static methods returning TypedResults can also be unit tested directly, which is quicker for business rules.
Validation#
For years, minimal APIs had no built-in validation: data annotations on a request type were simply ignored, and people added a filter or a library to run them. .NET 10 adds it to the framework:
builder.Services.AddValidation();public class CreateOrder{ [Required, EmailAddress] public string CustomerEmail { get; set; } = ""; [Range(1, 100)] public int Quantity { get; set; } [StringLength(200)] public string? Note { get; set; }}With that registered, a request whose body breaks a rule gets a 400 response with a problem details body listing each failing field, and the handler is never called. It works for parameters, request bodies and nested types, and it is driven by a source generator, so it does not depend on runtime reflection. Check the documentation for your exact version: the details changed during the .NET 10 preview cycle.
On .NET 8 and 9, you have three options. An endpoint filter that runs Validator.TryValidateObject and returns TypedResults.ValidationProblem(...). The small MiniValidation library, which does the same with nested-object support. Or FluentValidation, if you want rules in code rather than attributes, typically invoked from a filter. All three keep validation out of the handler itself.
Whichever you use, validate at the edge and trust the type afterwards. The database should still enforce what it can - not-null columns, unique indexes, foreign keys - because validation in the API protects one entry point and the database protects all of them.
Errors and problem details#
An unhandled exception in a minimal API returns an empty 500 by default in Production. Turn on the standard error format:
builder.Services.AddProblemDetails();var app = builder.Build();app.UseExceptionHandler();app.UseStatusCodePages();Now unhandled exceptions produce an application/problem+json response with a status and title, status codes without a body (a 404 from routing, a 401 from authentication) get one too, and the format matches the validation errors above. In Development the response includes exception details; in Production it does not, which is what you want. Log the exception on the server, return a correlation identifier to the client if you need to find it later, and never put exception messages from your database driver into a response.
For expected failures - a missing record, a conflict, a business rule - return the result explicitly (TypedResults.NotFound(), TypedResults.Conflict(), TypedResults.Problem(...)) rather than throwing. Exceptions are expensive and they make control flow hard to follow; a union return type makes the expected outcomes visible.
A common middle ground is a custom exception handler for a few known exception types - a concurrency conflict from EF Core becoming a 409, for example. Since .NET 8 you can implement IExceptionHandler, register it with AddExceptionHandler<T>(), and let it write a problem details response for the exceptions it recognises while passing the rest to the default behaviour.
OpenAPI documentation#
Since .NET 9, ASP.NET Core generates OpenAPI documents itself, through the Microsoft.AspNetCore.OpenApi package, and the templates no longer include Swashbuckle.
builder.Services.AddOpenApi();var app = builder.Build();app.MapOpenApi(); // serves /openapi/v1.json.NET 10 generates OpenAPI 3.1 by default. Endpoints are described from their route, parameters and return types; add .WithName("GetOrder"), .WithSummary(...) and .WithTags(...) for the parts the signature cannot say. TypedResults with union return types document every response automatically, which is the strongest argument for them.
The package produces the document, not a viewer. For an interactive page, add one: Scalar (Scalar.AspNetCore, then app.MapScalarApiReference()) or Swagger UI from the Swashbuckle.AspNetCore.SwaggerUI package pointed at /openapi/v1.json. Decide whether the document and the viewer should be public. For an internal API, map them only in Development or put them behind RequireAuthorization(); a public description of every endpoint is a gift to anyone probing your API.
Filters, authorisation and rate limits#
Endpoint filters run around a handler, with access to its arguments and its result:
group.AddEndpointFilter(async (context, next) =>{ var started = Stopwatch.GetTimestamp(); var result = await next(context); var ms = Stopwatch.GetElapsedTime(started).TotalMilliseconds; context.HttpContext.Response.Headers["Server-Timing"] = $"app;dur={ms:F0}"; return result;});Authorisation and rate limiting attach the same way. RequireAuthorization() with no argument requires an authenticated user; with a policy name it applies that policy. RequireRateLimiting("per-ip") applies a policy registered with AddRateLimiter. A rate limit keyed by IP needs the real client address, which behind a proxy means forwarded headers configured correctly - ASP.NET Core behind a reverse proxy shows how, and ASP.NET Core authentication basics covers cookies versus JWT for the authentication side.
If a browser application on another origin calls the API, it needs CORS. Register a named policy with the exact origins allowed (AddCors with WithOrigins("https://app.example.com")), add UseCors() to the pipeline before authorisation, and apply the policy to the group with RequireCors("app"). Do not combine AllowAnyOrigin() with credentials - browsers reject that pairing, and the error shows up only in the browser console, never in your server log. Preflight OPTIONS requests are answered by the CORS middleware itself, so they never reach your handlers and never need a route of their own.
When to use controllers instead#
Minimal APIs and controllers run on the same pipeline, and one app can contain both (AddControllers() plus MapControllers()). Reasons to choose controllers for some or all of an app:
- A large existing codebase or team habit. Rewriting working controllers buys little.
- Content negotiation. Controllers can return XML or other formats through formatters; minimal APIs are JSON-first.
- MVC filter ecosystems. Action filters, result filters and model binders from libraries you already depend on.
- OData, which has historically been built around controllers.
- Convention-heavy APIs, where attribute routing and inheritance from a base controller remove more repetition than route groups would.
Performance is not a strong reason either way. Minimal APIs do less work per request and allocate less, and they are the path to Native AOT, but for an API spending its time in the database the difference will not show. dotnet publish options covers AOT if startup time and memory matter to you.
Moving between the two does not have to happen all at once. An existing controller-based API can add new features as minimal API groups while old controllers keep working, and the reverse is just as possible. The routing table is shared, so the only rule is that two endpoints must not claim the same route and method; if they do, the request fails with an ambiguous match error that names both, which is easy to fix. Teams that migrate this way usually convert one feature at a time, starting with the simplest, and stop when the remaining controllers are not worth the effort.
Deploying a minimal API is the same as any ASP.NET Core app: publish, bind Kestrel to the allocated port on all interfaces, and put the domain on the proxy. Deploy an ASP.NET Core app from GitHub has the full sequence, and EF Core migrations in production covers the database schema that most APIs sit on.
FAQ#
Are minimal APIs only for small projects?
No. The name describes the amount of ceremony, not the size of the app. With route groups and one file per feature, a minimal API stays organised at hundreds of endpoints.
Can I use dependency injection in minimal API handlers?
Yes. Any parameter whose type is registered in the container is resolved per request, scoped services included. No constructor is needed.
Why is my request body always null?
The JSON does not match the parameter type, the Content-Type header is not application/json, or the method is GET, which does not bind a body by default. Check the request in the log and compare property names; binding is case-insensitive but spelling still matters.
Do I still need Swashbuckle?
Not to generate the document - Microsoft.AspNetCore.OpenApi does that from .NET 9. You only need a UI package if you want an interactive page, and Scalar or the Swagger UI package both work with the built-in document.
How do I version a minimal API?
The simplest way is a route group per version, /api/v1 and /api/v2, each mapping its own endpoints. The Asp.Versioning.Http package adds header- and query-based versioning if you need it.




Comments
Completely anonymous: no account, no email, no cookie. We store the name you type, the text and the time - nothing else. Links are limited and markup is not rendered.