Getting started with ASP.NET Core minimal APIs
A minimal API is just an endpoint on WebApplication — no controller class, no attribute routing, no base class to inherit. For small services, background endpoints and internal tools, that is often the whole architecture you need. The trade-off is that structure is no longer handed to you by the framework, so you have to put it in yourself.
This is the shape I start with: a few endpoints, services injected through DI, request/response contracts as records, and validation that runs before the handler body.
A small service, end to end
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDbContext<AppDbContext>(o =>
o.UseSqlServer(builder.Configuration.GetConnectionString("Db")));
builder.Services.AddScoped<IProductService, ProductService>();
builder.Services.AddProblemDetails();
var app = builder.Build();
app.MapGet("/products/{id:int}", async (int id, IProductService products, CancellationToken ct) =>
await products.GetAsync(id, ct) is { } product
? Results.Ok(product)
: Results.NotFound());
app.MapPost("/products", async (CreateProduct request, IProductService products, CancellationToken ct) =>
{
var created = await products.CreateAsync(request, ct);
return Results.Created($"/products/{created.Id}", created);
});
app.Run();
Two things are worth noticing. First, the handler parameters are resolved automatically: route values, query strings, the body, and any service registered in DI. Second, the handler returns an IResult, so you decide the status code and payload in one place instead of juggling a return type and an attribute.
Binding rules that catch people out
- Simple types (
int,string,Guid,DateTime) bind from the route or the query string. - Complex types bind from the JSON body, once per request. A second complex parameter throws at startup.
- Add
[FromBody],[FromQuery]or[FromHeader]when the default is ambiguous — being explicit is cheaper than debugging it later. - Nullable annotations are respected: a non-nullable complex parameter is required and produces a
400when the body is missing.
Validate at the edge
Data annotations still work, but minimal APIs do not run them for you on the way in as MVC does. An endpoint filter keeps the check out of the handler:
app.MapPost("/products", CreateProduct.Handle)
.AddEndpointFilter<ValidationFilter<CreateProduct>>();
public sealed class ValidationFilter<T> : IEndpointFilter
{
public async ValueTask<object?> InvokeAsync(
EndpointFilterInvocationContext context,
EndpointFilterDelegate next)
{
if (context.Arguments.OfType<T>().FirstOrDefault() is not { } model)
return Results.BadRequest();
var results = new List<ValidationResult>();
Validator.TryValidateObject(model, new ValidationContext(model), results, true);
return results.Count == 0
? await next(context)
: Results.ValidationProblem(results.ToDictionary(
r => r.MemberNames.FirstOrDefault() ?? "request",
r => new[] { r.ErrorMessage ?? "Invalid value" }));
}
}
Filters run around the handler and compose in registration order, which makes them a decent home for cross-cutting concerns: validation, logging scopes, a simple idempotency check.
Group endpoints as the file grows
A forty-endpoint Program.cs is the usual failure mode. Split by feature with route groups and an extension method per module:
public static class ProductEndpoints
{
public static IEndpointRouteBuilder MapProducts(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/products").WithTags("Products");
group.MapGet("/", GetAll);
group.MapGet("/{id:int}", GetById);
group.MapPost("/", Create).AddEndpointFilter<ValidationFilter<CreateProduct>>();
return app;
}
}
Each module stays small, the map is obvious, and you can unit test the handlers as ordinary static methods because they take their dependencies as parameters.
Document it
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
// after builder.Build()
app.UseSwagger();
app.UseSwaggerUI();
Minimal APIs are annotated automatically from signatures and result types, so the generated document is usually accurate without extra attributes. Add Produces<T> only where the response is genuinely dynamic.
When I still use controllers
Minimal APIs are not a replacement for every MVC application. I reach for controllers when I need model binding with complex form posts, heavy action filters, or a team already fluent in that layout. For a service with a dozen JSON endpoints, the minimal version is less code, faster to start, and easier to read — and the handler-as-static-method style makes the business logic testable without a web host.
Keep handlers thin: bind, delegate, return a result. If a handler is longer than a screen, the logic belongs in a service, not in the route.