ASP.NET Core Minimal APIs vs Controllers: Which Should You Use in 2026?

·

·

When Minimal APIs shipped in .NET 6, the comparison was unfair — they lacked filters, had limited testing support, and couldn’t match controllers for anything beyond simple CRUD. That’s no longer true. By .NET 10 in 2026, Minimal APIs have endpoint filters, route groups, OpenAPI support, and all the features needed for production APIs.

The real question now: which approach makes sense for your project? Here’s the honest answer.

Table of Contents

Syntax and Code Organization

Minimal API

// Program.cs
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddScoped<IProductService, ProductService>();

var app = builder.Build();

var productsGroup = app.MapGroup("/api/products").RequireAuthorization();

productsGroup.MapGet("/", async (IProductService svc) =>
    Results.Ok(await svc.GetAllAsync()));

productsGroup.MapGet("/{id:int}", async (int id, IProductService svc) =>
    await svc.GetByIdAsync(id) is Product p
        ? Results.Ok(p)
        : Results.NotFound());

productsGroup.MapPost("/", async (CreateProductDto dto, IProductService svc) =>
{
    var product = await svc.CreateAsync(dto);
    return Results.CreatedAtRoute("GetProduct", new { id = product.Id }, product);
});

app.Run();

Controller

// ProductsController.cs
[ApiController]
[Route("api/[controller]")]
[Authorize]
public class ProductsController : ControllerBase
{
    private readonly IProductService _svc;
    public ProductsController(IProductService svc) => _svc = svc;

    [HttpGet]
    public async Task<IActionResult> GetAll()
        => Ok(await _svc.GetAllAsync());

    [HttpGet("{id:int}", Name = "GetProduct")]
    public async Task<IActionResult> GetById(int id)
    {
        var product = await _svc.GetByIdAsync(id);
        return product is null ? NotFound() : Ok(product);
    }

    [HttpPost]
    public async Task<IActionResult> Create(CreateProductDto dto)
    {
        var product = await _svc.CreateAsync(dto);
        return CreatedAtRoute("GetProduct", new { id = product.Id }, product);
    }
}

Both do the same thing. Controllers are more verbose but every piece is explicitly structured. Minimal APIs are terser but can become unreadable if you dump 200 endpoints into Program.cs.

Performance

Minimal APIs have measurably lower overhead than controllers for simple endpoints. In Microsoft’s benchmarks, Minimal APIs process more requests per second on identical hardware — the MVC middleware pipeline that controllers use has more layers.

Real Numbers (Approximate, .NET 10)

  • Minimal API hello-world endpoint: ~1.1M RPS (single core, in-process benchmark)
  • Controller hello-world endpoint: ~900K RPS
  • Difference on real-world endpoints with DB calls: negligible (5–10%)

For most production APIs, the bottleneck is the database — not the routing layer. The performance difference matters only at extreme scale (millions of RPS) or for very hot, very lightweight endpoints.

Route Groups and Organization at Scale

The early criticism of Minimal APIs — “they don’t scale to large APIs” — was addressed with route groups. You can organize endpoints into logical groups, apply shared middleware, and split them across files.

// ProductEndpoints.cs — extracted to its own file
public static class ProductEndpoints
{
    public static RouteGroupBuilder MapProducts(this RouteGroupBuilder group)
    {
        group.MapGet("/", GetAll);
        group.MapGet("/{id:int}", GetById);
        group.MapPost("/", Create);
        group.MapPut("/{id:int}", Update);
        group.MapDelete("/{id:int}", Delete);
        return group;
    }

    private static async Task<IResult> GetAll(IProductService svc)
        => Results.Ok(await svc.GetAllAsync());

    private static async Task<IResult> GetById(int id, IProductService svc)
        => await svc.GetByIdAsync(id) is Product p ? Results.Ok(p) : Results.NotFound();

    private static async Task<IResult> Create(CreateProductDto dto, IProductService svc)
    {
        var product = await svc.CreateAsync(dto);
        return Results.CreatedAtRoute("GetProduct", new { id = product.Id }, product);
    }

    // Update and Delete methods...
}

// Program.cs — clean and readable
var apiGroup = app.MapGroup("/api").RequireAuthorization();
apiGroup.MapGroup("/products").MapProducts();
apiGroup.MapGroup("/orders").MapOrders();
apiGroup.MapGroup("/customers").MapCustomers();

This pattern gives you the same file-per-resource organization as controllers — just without the class ceremony.

Filters and Middleware

Endpoint Filters (Minimal API)

// Equivalent to action filters in controllers
public class ValidationFilter<T> : IEndpointFilter
{
    public async ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext context,
                                                 EndpointFilterDelegate next)
    {
        var argument = context.GetArgument<T>(0);
        var validator = context.HttpContext.RequestServices.GetRequiredService<IValidator<T>>();

        var result = await validator.ValidateAsync(argument!);
        if (!result.IsValid)
            return Results.ValidationProblem(result.ToDictionary());

        return await next(context);
    }
}

// Apply to a route group
productsGroup.AddEndpointFilter<ValidationFilter<CreateProductDto>>();

Controller Action Filters (comparison)

public class ValidateModelAttribute : ActionFilterAttribute
{
    public override void OnActionExecuting(ActionExecutingContext context)
    {
        if (!context.ModelState.IsValid)
            context.Result = new UnprocessableEntityObjectResult(context.ModelState);
    }
}

[ValidateModel]
public async Task<IActionResult> Create(CreateProductDto dto) { ... }

Both work. Controller filters have a more established ecosystem (FluentValidation ASP.NET integration, etc.). Endpoint filters are newer but fully capable.

Testing

Both approaches support integration testing via WebApplicationFactory.

// Integration test — works identically for both Minimal API and Controllers
public class ProductsApiTests : IClassFixture<WebApplicationFactory<Program>>
{
    private readonly HttpClient _client;

    public ProductsApiTests(WebApplicationFactory<Program> factory)
    {
        _client = factory.WithWebHostBuilder(builder =>
        {
            builder.ConfigureServices(services =>
            {
                // Replace real service with fake
                services.AddScoped<IProductService, FakeProductService>();
            });
        }).CreateClient();
    }

    [Fact]
    public async Task GetProducts_ReturnsOk()
    {
        var response = await _client.GetAsync("/api/products");
        response.EnsureSuccessStatusCode();

        var products = await response.Content.ReadFromJsonAsync<List<Product>>();
        Assert.NotNull(products);
    }
}

Unit testing difference: Controllers are plain classes — you can new them up and call methods directly in unit tests. Minimal API handlers are typically static methods or lambdas — you test them through WebApplicationFactory or extract the logic to service classes tested independently.

OpenAPI / Swagger

.NET 9+ includes a built-in OpenAPI document generation package (Microsoft.AspNetCore.OpenApi) that works for both approaches.

// Minimal API — describe endpoints explicitly
productsGroup.MapGet("/{id:int}", GetById)
    .WithName("GetProduct")
    .WithSummary("Get a product by ID")
    .WithDescription("Returns a single product or 404 if not found")
    .Produces<Product>(200)
    .Produces(404)
    .WithTags("Products");
// Controller — XML docs + data annotations drive OpenAPI
/// <summary>Get a product by ID</summary>
/// <response code="200">Returns the product</response>
/// <response code="404">Product not found</response>
[HttpGet("{id:int}", Name = "GetProduct")]
[ProducesResponseType(typeof(Product), 200)]
[ProducesResponseType(404)]
public async Task<IActionResult> GetById(int id) { ... }

Controllers generate OpenAPI schemas more automatically (via model binding metadata). Minimal APIs require more explicit .Produces<T>() declarations but give you finer control over the document.

DI, Validation, and Model Binding

Dependency Injection

// Minimal API — services injected as parameters (works via DI)
app.MapGet("/products", async (IProductService svc, ILogger<Program> logger) =>
{
    logger.LogInformation("Getting all products");
    return Results.Ok(await svc.GetAllAsync());
});

// Controller — constructor injection
public class ProductsController : ControllerBase
{
    public ProductsController(IProductService svc, ILogger<ProductsController> logger) { }
}

Model Validation

// Minimal API — manual validation (or use endpoint filters)
app.MapPost("/products", async (CreateProductDto dto, IValidator<CreateProductDto> validator) =>
{
    var result = await validator.ValidateAsync(dto);
    if (!result.IsValid)
        return Results.ValidationProblem(result.ToDictionary());
    // proceed
});

// Controller — automatic with [ApiController]
// ModelState is checked before your action method runs
[HttpPost]
public async Task<IActionResult> Create(CreateProductDto dto)
{
    // ModelState already validated — [ApiController] returned 400 if invalid
}

Controllers win on validation convenience. [ApiController] automatically validates and returns 400 responses. Minimal APIs require explicit validation code or endpoint filters — more work upfront, but more control.

Decision Framework: Which to Choose

Choose Minimal APIs When:

  • Building microservices or small, focused APIs (5–20 endpoints)
  • Performance is a primary concern (high-throughput, low-latency endpoints)
  • You want minimal infrastructure and a clean, flat codebase
  • Building Azure Functions-style or serverless endpoints
  • Your team is comfortable with functional/composition-style code organization

Choose Controllers When:

  • Building large APIs with 50+ endpoints
  • Team is familiar with MVC conventions (common in enterprise teams)
  • You need action filter ecosystem compatibility (existing filters, libraries)
  • Automatic model validation via [ApiController] is important
  • You need OData or advanced routing features better supported by controllers
  • API versioning with Asp.Versioning (better controller support)

Use Both (Hybrid Approach)

Nothing prevents using both in one project. Use Minimal APIs for lightweight health checks, webhooks, and simple CRUD; use Controllers for complex business logic with rich filter pipelines. ASP.NET Core routes both identically.

FAQ

Are Minimal APIs production-ready in 2026?

Fully. Microsoft uses them internally, and they power some of the highest-traffic .NET services. The “not production ready” concerns from 2022 are resolved — endpoint filters, route groups, OpenAPI, and testing support are all mature.

Can I mix Minimal APIs and Controllers in one project?

Yes — add builder.Services.AddControllers() and use both. They share the same middleware pipeline and DI container with no conflict.

Do Minimal APIs support versioning?

Yes, via the Asp.Versioning.Http package. Controllers have slightly more mature versioning support but Minimal APIs are catching up. For new projects, both approaches work.

What about gRPC or SignalR — do Minimal APIs work there?

gRPC services still use their own infrastructure (separate from both controllers and Minimal APIs). SignalR hubs have their own routing. Minimal APIs are specifically for HTTP REST/RPC endpoints.

Can I use attribute-based routing in Minimal APIs?

Not with attributes — routes are defined fluently (.MapGet("/path")). If attribute routing is important to your team’s conventions, controllers are the better fit.

Conclusion

In 2026, choosing Minimal APIs or Controllers is a team and project fit decision, not a capability decision. Minimal APIs are leaner, faster, and excellent for microservices and focused APIs. Controllers are more structured, have better validation automation, and remain the right choice for large APIs built by teams with MVC backgrounds.

If you’re starting a new project today and your API is reasonably small: try Minimal APIs with route groups. The code is cleaner, the performance is better, and the mental model is simpler. If you’re maintaining a large existing controller-based API: don’t rewrite it — controllers are fine and will be supported indefinitely.

[INTERNAL_LINK: EF Core 10 new features guide] [INTERNAL_LINK: .NET Aspire explained for .NET developers]


Leave a Reply

Your email address will not be published. Required fields are marked *