Minimal APIs vs Controllers in ASP.NET Core: What I Pick and Why

Every new ASP.NET Core project starts with the same architecture discussion, and since .NET 6 it has a new first question: minimal APIs or controllers? I've shipped production services with both, and the honest answer is that the choice matters less than people think — but the reasons for choosing matter a lot. Here's the way I actually decide.

The same endpoint, both ways

Controller style:

csharp
[ApiController]
[Route("api/orders")]
public class OrdersController : ControllerBase
{
    private readonly IOrderService _orders;

    public OrdersController(IOrderService orders) => _orders = orders;

    [HttpGet("{id:int}")]
    [ProducesResponseType<OrderDto>(StatusCodes.Status200OK)]
    [ProducesResponseType(StatusCodes.Status404NotFound)]
    public async Task<IActionResult> GetById(int id, CancellationToken ct)
    {
        var order = await _orders.FindAsync(id, ct);
        return order is null ? NotFound() : Ok(order);
    }
}

Minimal API style:

csharp
app.MapGet("/api/orders/{id:int}",
    async Task<Results<Ok<OrderDto>, NotFound>> (
        int id, IOrderService orders, CancellationToken ct) =>
    {
        var order = await orders.FindAsync(id, ct);
        return order is null
            ? TypedResults.NotFound()
            : TypedResults.Ok(order);
    });

Both are dependency-injected, testable, OpenAPI-documented endpoints. The minimal version has less ceremony; the controller version has more established structure. Neither is "the toy one" — minimal APIs are a first-class, high-performance production feature, and with TypedResults they're arguably more explicit about response types than IActionResult ever was.

Where minimal APIs genuinely win

Less indirection per endpoint. The route, the handler, and its dependencies sit in one place. For services with a modest number of endpoints, navigating the codebase gets noticeably faster — no hopping between attribute routes and base classes.

Performance. Minimal APIs skip parts of the MVC pipeline (filters, model-binding machinery, action invocation layers) and benchmark measurably better in throughput and startup time. For most business APIs this is irrelevant — your database is the bottleneck — but for high-QPS internal services and serverless cold starts, it's real.

Native AOT compatibility. If you're compiling to native AOT (fast startup, small footprint), minimal APIs are the supported path; MVC controllers are not AOT-friendly. For containerized microservices where image size and cold start matter, this alone can decide it.

Granular composition. Endpoint filters, route groups, and handler-level DI make cross-cutting behavior compositional rather than attribute-driven:

csharp
var orders = app.MapGroup("/api/orders")
    .RequireAuthorization()
    .AddEndpointFilter<ValidationFilter>()
    .WithTags("Orders");

orders.MapGet("/{id:int}", GetOrderById);
orders.MapPost("/", CreateOrder);

Where controllers still earn their keep

Convention at scale. On a large API with many teams, controllers impose a shared shape: everyone knows where routes live, where filters go, how model validation behaves. Minimal APIs give you freedom, and freedom across 40 developers needs replacement conventions you now have to write and enforce yourself.

The mature filter/model-binding ecosystem. [ApiController] gives automatic 400s from data annotations, rich model binding (form posts, complex query objects), action filters, and years of middleware and library integrations that assume MVC. Minimal APIs have closed most of the gap (validation support arrived in .NET 10), but if your app leans hard on custom model binders or action filters, controllers remain the path of least resistance.

Existing codebases. A working controller-based API gains almost nothing from a rewrite. Architectural churn without user-visible benefit is how teams burn quarters.

The trap to avoid with minimal APIs

The demo style — fifty lambdas in Program.cs — does not scale past a handful of endpoints. Production minimal APIs need structure, and the pattern that works is grouping endpoints into static classes with mapping extensions:

csharp
public static class OrderEndpoints
{
    public static IEndpointRouteBuilder MapOrderEndpoints(
        this IEndpointRouteBuilder app)
    {
        var group = app.MapGroup("/api/orders").WithTags("Orders");
        group.MapGet("/{id:int}", GetById);
        group.MapPost("/", Create);
        return app;
    }

    private static async Task<Results<Ok<OrderDto>, NotFound>> GetById(
        int id, IOrderService orders, CancellationToken ct) => ...
}

Named static methods are unit-testable exactly like controller actions, and Program.cs stays a table of contents (app.MapOrderEndpoints()). If you adopt minimal APIs, adopt this structure on day one.

How I actually choose

  1. New microservice, small-to-medium surface, container/AOT deployment? Minimal APIs, with the endpoint-class structure above.
  2. Large multi-team API, heavy filter/model-binding usage, or an org fluent in MVC? Controllers, no apology needed.
  3. Existing controller codebase? Stay. Add minimal API endpoints for new isolated modules if you want the on-ramp.
  4. Mixed? Fully supported — both styles share routing, auth, DI, and OpenAPI in one app. This is often the pragmatic answer during a transition.

The real architectural risk isn't picking the wrong endpoint style — it's letting either style tempt you into skipping structure. Thin HTTP layer, business logic behind interfaces, DTOs at the boundary: get that right and switching endpoint styles later is a mechanical refactor, not a rewrite.

Where to go next