1. The Problem Context β Controllers Aren't Free
ASP.NET Core MVC controllers carry a lot of weight: filters, model binding pipelines, action invokers, results executors, view engines you don't use. For an HTTP API that returns JSON, most of that machinery sits idle while still imposing per-request overhead. Minimal APIs, introduced in .NET 6 and matured through .NET 9, strip the model down to its essence: a route, a delegate, a result. The wins are smaller binaries, faster cold starts (especially on Azure Container Apps and AOT), and code that fits on a screen.
2. The Concept Explanation β Endpoints, Filters, Results
A Minimal API endpoint is three things: a route pattern, a handler delegate (with DI injected through parameters), and a result. The framework handles parameter binding from route, query, header, body, and DI based on type. Cross-cutting behaviour goes into endpoint filters, the lighter-weight equivalent of MVC action filters. Endpoints compose into route groups, which scope authorization, filters, OpenAPI metadata, and prefixes.
flowchart LR R[Request] --> B[Parameter Binding] B --> F1[Endpoint Filter 1] F1 --> F2[Endpoint Filter 2 ...] F2 --> H[Handler Delegate] H --> T[Typed Result<Ok, NotFound, ...>] T --> W[Response Writer] W --> C[Client]
The three doctrines worth internalizing:
- Use typed results.
Results<Ok<T>, NotFound, ValidationProblem>makes every possible response part of the method signature β and OpenAPI picks up every variant automatically. - Group ruthlessly. One
MapGroupper resource (or per version). Auth, filters, rate limits, and tags attach to the group, not to every endpoint. - Use endpoint filters for cross-cutting behaviour. Validation, audit logging, idempotency keys β never repeat them in handler bodies.
3. The Implementation β A Production-Shaped Service
3.1 Wiring the host
.NET 9 ships built-in OpenAPI support via Microsoft.AspNetCore.OpenApi, replacing Swashbuckle for spec generation. Use Scalar or Swagger UI as a viewer if you want one.
var builder = WebApplication.CreateBuilder(args);
builder.Services
.AddOpenApi() // .NET 9 native
.AddProblemDetails()
.AddRateLimiter(o => o.AddFixedWindowLimiter("api", w => { w.Window = TimeSpan.FromSeconds(10); w.PermitLimit = 100; }))
.AddAuthorization()
.AddAuthentication().AddJwtBearer();
builder.Services.AddKeyedScoped<IOrderRepository, SqlOrderRepository>("primary");
builder.Services.AddKeyedScoped<IOrderRepository, ReadReplicaOrderRepository>("replica");
var app = builder.Build();
app.UseExceptionHandler();
app.UseRateLimiter();
app.UseAuthentication();
app.UseAuthorization();
app.MapOpenApi(); // serves /openapi/v1.json
app.MapOrders(); // route group below
app.Run();3.2 A route group with filters and typed results
public static class OrdersModule
{
public static IEndpointRouteBuilder MapOrders(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/v1/orders")
.WithTags("Orders")
.RequireAuthorization()
.RequireRateLimiting("api")
.AddEndpointFilter<ValidationFilter>();
group.MapPost("/", PlaceOrder).WithName("PlaceOrder");
group.MapGet("/{id:guid}", GetOrder).WithName("GetOrder");
group.MapDelete("/{id:guid}", CancelOrder).WithName("CancelOrder");
return app;
}
private static async Task<Results<Created<OrderDto>, ValidationProblem>> PlaceOrder(
PlaceOrderRequest body,
[FromKeyedServices("primary")] IOrderRepository repo,
TimeProvider clock,
CancellationToken ct)
{
var order = Order.Place(body.CustomerId, body.Lines, clock);
await repo.AddAsync(order, ct);
var dto = OrderDto.From(order);
return TypedResults.Created($"/api/v1/orders/{dto.Id}", dto);
}
private static async Task<Results<Ok<OrderDto>, NotFound>> GetOrder(
Guid id,
[FromKeyedServices("replica")] IOrderRepository repo,
CancellationToken ct)
{
var order = await repo.FindAsync(new OrderId(id), ct);
return order is null ? TypedResults.NotFound() : TypedResults.Ok(OrderDto.From(order));
}
private static async Task<Results<NoContent, NotFound, ProblemHttpResult>> CancelOrder(
Guid id,
[FromKeyedServices("primary")] IOrderRepository repo,
CancellationToken ct)
{
try
{
var ok = await repo.TryCancelAsync(new OrderId(id), ct);
return ok ? TypedResults.NoContent() : TypedResults.NotFound();
}
catch (DomainException ex)
{
return TypedResults.Problem(ex.Message, statusCode: 409);
}
}
}Every endpoint declares its full response surface in the return type. OpenAPI infers all of it; clients generated from the spec see 201, 400, 404, 409 as exhaustive, typed responses. No XML doc-comment gymnastics.
3.3 An endpoint filter β validation done once
public sealed class ValidationFilter(IServiceProvider sp) : IEndpointFilter
{
public async ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext ctx, EndpointFilterDelegate next)
{
foreach (var arg in ctx.Arguments)
{
if (arg is null) continue;
var validatorType = typeof(IValidator<>).MakeGenericType(arg.GetType());
if (sp.GetService(validatorType) is IValidator validator)
{
var result = await validator.ValidateAsync(new ValidationContext<object>(arg));
if (!result.IsValid)
return TypedResults.ValidationProblem(result.ToDictionary());
}
}
return await next(ctx);
}
}3.4 Keyed services in handlers (.NET 8+)
[FromKeyedServices("replica")] resolves a specific implementation registered with that key β useful for read replica routing, multi-tenant strategy selection, or feature-flag-driven swaps without an abstract factory.
3.5 Testing with WebApplicationFactory
public sealed class OrdersApiTests(WebApplicationFactory<Program> factory) : IClassFixture<WebApplicationFactory<Program>>
{
[Fact]
public async Task Place_returns_201_with_location()
{
var client = factory.WithWebHostBuilder(b => b.ConfigureServices(s =>
{
s.RemoveAll<DbContextOptions<AppDb>>();
s.AddDbContext<AppDb>(o => o.UseSqlite("DataSource=:memory:"));
})).CreateClient();
var response = await client.PostAsJsonAsync("/api/v1/orders", new PlaceOrderRequest(/* ... */));
response.StatusCode.Should().Be(HttpStatusCode.Created);
response.Headers.Location.Should().NotBeNull();
}
}4. Pitfalls That Negate the Wins
- Returning
IResultinstead of typed results. OpenAPI loses response metadata and clients fall back toany. Always project toResults<...>. - Putting business logic in the handler delegate. The delegate is a thin edge β bind, dispatch to a handler/use-case, project the result. A 50-line lambda is a refactor signal.
- Re-implementing filters per endpoint. Validation, idempotency, audit logging β extract them into endpoint filters and attach them to the group.
- Swashbuckle in .NET 9. The built-in
AddOpenApi+ source-generator path is the supported route now. Swashbuckle is no longer the default template, and using both fights for control of the spec. - Forgetting the
CancellationToken. Always include it as a handler parameter. Long-running queries that ignore client cancellation hold connections open and starve the thread pool. - Inferring binding source from convention. Bind a primitive from the route, a complex object from the body, an
IFormFilefrom form data β but be explicit with[FromBody],[FromQuery],[FromHeader]when the intent isn't obvious. Saves a class of debugging sessions. - One file per endpoint. Nope. Group endpoints by feature/resource into a static module class with a
Map*extension method. Vertical slices apply here too.
5. Practical Takeaways
- Default to Minimal APIs for new HTTP services. Reach for Controllers when you need MVC features (Razor views, sophisticated model binding conventions, legacy filters).
- Use
MapGroupliberally. Authorisation, rate limiting, and OpenAPI tags attach to the group, not to every endpoint. - Return typed results. Make every status code part of the contract.
- Cross-cutting behaviour goes in endpoint filters. Don't repeat it across handlers.
- Use
[FromKeyedServices]for replica/strategy/multi-tenant resolution without abstract factories. - Use the .NET 9 native
AddOpenApi; pick a viewer (Scalar, Swagger UI) only if you need one. - Test with
WebApplicationFactory<Program>β the same approach you used for Controllers works unchanged. - Test for AOT compatibility early if you're targeting Azure Container Apps with Native AOT. Reflection-heavy filters (some validation libraries) are the usual stumbling block.

