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.

πŸ€” Sound familiar?
Every Minimal API critique from 2022 β€” β€œno validation, no versioning, no OpenAPI, can't test it” β€” has been answered by .NET 8 and .NET 9. In 2026 the choice between Controllers and Minimal APIs is a style preference for most projects and a meaningful performance/AOT decision for a growing minority.

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 MapGroup per 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 =&gt; o.AddFixedWindowLimiter("api", w =&gt; { w.Window = TimeSpan.FromSeconds(10); w.PermitLimit = 100; }))
    .AddAuthorization()
    .AddAuthentication().AddJwtBearer();

builder.Services.AddKeyedScoped&lt;IOrderRepository, SqlOrderRepository&gt;("primary");
builder.Services.AddKeyedScoped&lt;IOrderRepository, ReadReplicaOrderRepository&gt;("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&lt;ValidationFilter&gt;();

        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&lt;Results&lt;Created&lt;OrderDto&gt;, ValidationProblem&gt;&gt; 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&lt;Results&lt;Ok&lt;OrderDto&gt;, NotFound&gt;&gt; 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&lt;Results&lt;NoContent, NotFound, ProblemHttpResult&gt;&gt; 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&lt;object?&gt; InvokeAsync(EndpointFilterInvocationContext ctx, EndpointFilterDelegate next)
    {
        foreach (var arg in ctx.Arguments)
        {
            if (arg is null) continue;
            var validatorType = typeof(IValidator&lt;&gt;).MakeGenericType(arg.GetType());
            if (sp.GetService(validatorType) is IValidator validator)
            {
                var result = await validator.ValidateAsync(new ValidationContext&lt;object&gt;(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&lt;Program&gt; factory) : IClassFixture&lt;WebApplicationFactory&lt;Program&gt;&gt;
{
    [Fact]
    public async Task Place_returns_201_with_location()
    {
        var client = factory.WithWebHostBuilder(b =&gt; b.ConfigureServices(s =&gt;
        {
            s.RemoveAll&lt;DbContextOptions&lt;AppDb&gt;&gt;();
            s.AddDbContext&lt;AppDb&gt;(o =&gt; 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 IResult instead of typed results. OpenAPI loses response metadata and clients fall back to any. Always project to Results<...>.
  • 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 IFormFile from 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 MapGroup liberally. 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.