.NET Programming With Me

Minimal APIs vs Controllers in ASP.NET Core → Which Should You Choose?

If you've started a new ASP.NET Core project recently, you've probably stared at that first screen in Visual Studio and asked yourself: Minimal APIs or Controllers?

Microsoft introduced Minimal APIs in .NET 6 as a lighter, faster alternative to the traditional MVC controller pattern. Four releases later, both approaches are mature, production-ready, and genuinely good. The question isn't which one is better; it's which one is right for your project.

This post gives you the honest comparison no one else does: real code side-by-side, clear tradeoffs, and a decision framework you can apply today.


1 A quick history first

MVC Controllers have been the backbone of ASP.NET since 2009. They're class-based, rely on attribute routing, support model binding, filters, and all the conventions most .NET developers know by heart.

Minimal APIs landed in .NET 6 (2021) with a clear goal: reduce boilerplate, improve cold-start performance, and make simple APIs fast to write. They're function-based, so you define routes directly in Program.cs or a lightweight extension method.

Both approaches compile down to the same underlying ASP.NET Core pipeline. Neither is a fundamentally different technology; they're different organizational patterns over the same runtime.


2 The same endpoint, two ways

Before diving into tradeoffs, let's see both styles solving an identical problem: a simple Products API.

Controller approach

// ProductsController.cs
[ApiController]
[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
    private readonly IProductService _service;

    public ProductsController(IProductService service)
    {
        _service = service;
    }

    [HttpGet]
    public async Task<IActionResult> GetAll()
    {
        var products = await _service.GetAllAsync();
        return Ok(products);
    }

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

    [HttpPost]
    public async Task<IActionResult> Create([FromBody] CreateProductRequest request)
    {
        var product = await _service.CreateAsync(request);
        return CreatedAtAction(nameof(GetById), new { id = product.Id }, product);
    }

    [HttpDelete("{id:int}")]
    public async Task<IActionResult> Delete(int id)
    {
        var deleted = await _service.DeleteAsync(id);
        if (!deleted)
            return NotFound();
        return NoContent();
    }
}

Minimal API approach

// Program.cs (or ProductEndpoints.cs using extension method)
app.MapGet("/api/products", async (IProductService service) =>
    await service.GetAllAsync());

app.MapGet("/api/products/{id:int}", async (int id, IProductService service) =>
{
    var product = await service.GetByIdAsync(id);
    return product is null ? Results.NotFound() : Results.Ok(product);
});

app.MapPost("/api/products", async (CreateProductRequest request, IProductService service) =>
{
    var product = await service.CreateAsync(request);
    return Results.CreatedAtRoute("GetProductById", new { id = product.Id }, product);
});

app.MapDelete("/api/products/{id:int}", async (int id, IProductService service) =>
{
    var deleted = await service.DeleteAsync(id);
    return deleted ? Results.NoContent() : Results.NotFound();
});

Both achieve exactly the same result. The controller version is ~40 lines; the Minimal API version is ~15 lines. But lines of code is the least interesting difference. Let's get into what actually matters.


3 The real tradeoffs

1. Startup performance and cold starts

Minimal APIs have a measurable advantage here. Because they skip the MVC middleware layer and avoid controller discovery and reflection-heavy model binding setup, they start faster and use less memory at idle.

In benchmarks for small APIs (Microsoft's own published numbers), Minimal APIs can handle ~800,000 requests/second on modern hardware vs ~650,000 for controllers, which is roughly a 20% throughput improvement in pure API scenarios.

Does this matter to you? Only if you're building microservices where cold start time is critical (e.g., serverless on Azure Functions or AWS Lambda), or if you're squeezing performance out of high-throughput services. For most business applications, the difference is invisible.

2. Code organization as the app grows

This is where controllers have a genuine advantage that often gets ignored in "Minimal APIs are the future" posts.

Controllers give you automatic organization: one class per resource, one file per controller, conventional folder structure (/Controllers). Developers joining your project know exactly where to look.

Minimal APIs give you flexibility, which is great when you know what you're doing and risky when you don't. A large Minimal API app with no structure looks like this after 6 months:

// Program.cs - the graveyard of intentions
app.MapGet("/api/products", ...);
app.MapGet("/api/products/{id}", ...);
app.MapPost("/api/products", ...);
app.MapGet("/api/orders", ...);
app.MapPost("/api/orders", ...);
app.MapGet("/api/customers", ...);
// ... 200 more lines

The solution is to use endpoint extension classes, but now you're essentially reinventing controllers with different syntax.

// ProductEndpoints.cs - the right way to scale Minimal APIs
public static class ProductEndpoints
{
    public static IEndpointRouteBuilder MapProductEndpoints(this IEndpointRouteBuilder app)
    {
        app.MapGet("/api/products", GetAll);
        app.MapGet("/api/products/{id:int}", GetById);
        app.MapPost("/api/products", Create);
        app.MapDelete("/api/products/{id:int}", Delete);
        return app;
    }

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

    private static async Task<IResult> GetById(int id, IProductService service)
    {
        var product = await service.GetByIdAsync(id);
        return product is null ? Results.NotFound() : Results.Ok(product);
    }

    // ... rest of handlers
}

// Program.cs stays clean
app.MapProductEndpoints();
app.MapOrderEndpoints();

This pattern is clean and scalable, but it requires discipline your team has to agree on upfront.

3. Dependency injection

Both approaches support DI fully, but the syntax differs.

Controllers get dependencies via constructor injection, familiar to anyone who has used .NET DI:

public class OrdersController : ControllerBase
{
    private readonly IOrderService _orders;
    private readonly ILogger<OrdersController> _logger;

    public OrdersController(IOrderService orders, ILogger<OrdersController> logger)
    {
        _orders = orders;
        _logger = logger;
    }
}

Minimal APIs inject directly into the handler signature, which is great for simple cases but noisy for complex ones:

app.MapPost("/api/orders", async (
    CreateOrderRequest request,
    IOrderService orders,
    IInventoryService inventory,
    INotificationService notifications,
    ILogger<Program> logger) =>
{
    // When you have 5+ dependencies, this gets unwieldy fast
});

For endpoints with many dependencies, Minimal APIs become harder to read. The workaround is to inject a facade service that aggregates dependencies, but again, this is discipline you must enforce rather than a pattern the framework gives you for free.

4. Filters and cross-cutting concerns

This is where controllers still have a real edge. The MVC filter pipeline is one of ASP.NET's most powerful features:

// One attribute covers all controller actions
[ServiceFilter(typeof(AuditLogFilter))]
[RequiresPermission("admin")]
[RateLimit(100)]
public class AdminController : ControllerBase { ... }

Minimal APIs have endpoint filters (added in .NET 7), which are good but require more explicit wiring:

app.MapPost("/api/admin/users", CreateUser)
   .AddEndpointFilter<AuditLogFilter>()
   .RequireAuthorization("AdminPolicy")
   .WithMetadata(new RateLimitAttribute(100));

The filter pipeline for controllers is richer and more mature. If your app has complex cross-cutting concerns (audit logging, permission checks, rate limiting, tenant resolution), controllers are easier to work with today.

5. Model validation

Controllers give you automatic model validation via the [ApiController] attribute:

[ApiController]
public class ProductsController : ControllerBase
{
    [HttpPost]
    public IActionResult Create(CreateProductRequest request)
    {
        // ModelState is automatically validated BEFORE this runs
        // Invalid requests return 400 automatically - no code needed
        return Ok();
    }
}

public class CreateProductRequest
{
    [Required]
    [MaxLength(100)]
    public string Name { get; set; }

    [Range(0.01, 999999)]
    public decimal Price { get; set; }
}

Minimal APIs do not automatically validate models. You have to do it yourself:

app.MapPost("/api/products", async (
    CreateProductRequest request,
    IValidator<CreateProductRequest> validator) =>
{
    var result = await validator.ValidateAsync(request);
    if (!result.IsValid)
        return Results.ValidationProblem(result.ToDictionary());

    // proceed...
});

You're likely adding FluentValidation anyway for complex rules, but for simple validation, controllers require zero extra setup.

6. Testing

Both are highly testable, but the mechanisms differ.

Controller testing with WebApplicationFactory is well-documented and familiar:

public class ProductsControllerTests : IClassFixture<WebApplicationFactory<Program>>
{
    private readonly HttpClient _client;

    public ProductsControllerTests(WebApplicationFactory<Program> factory)
    {
        _client = factory.CreateClient();
    }

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

Minimal API handler testing is actually simpler when you extract handlers into static methods. Because they're just functions, you can test them in isolation without spinning up a full server:

[Fact]
public async Task GetById_ReturnsNotFound_WhenProductMissing()
{
    var service = Substitute.For<IProductService>();
    service.GetByIdAsync(99).Returns((Product?)null);

    var result = await ProductEndpoints.GetById(99, service);

    result.Should().BeOfType<NotFound>();
}

This is a genuine Minimal API advantage for unit testing: handlers are easier to test in isolation when they're pure functions rather than class methods.


4 Swagger / OpenAPI support

Both approaches work with Swagger, but there's a gotcha.

Controllers auto-generate rich OpenAPI documentation from your attributes and response types. Minimal APIs require you to be more explicit:

app.MapGet("/api/products/{id:int}", GetById)
   .WithName("GetProductById")
   .WithOpenApi(op =>
   {
       op.Summary = "Get a product by ID";
       op.Parameters[0].Description = "The product identifier";
       return op;
   })
   .Produces<Product>(200)
   .Produces(404);

In .NET 9, Microsoft.AspNetCore.OpenApi has improved significantly, but controllers still require less manual work to produce clean API documentation.


5 The decision framework

Here's how to make the call for your project:

Choose Controllers when:

  • Your team is new to ASP.NET Core or primarily seniors who learned with MVC
  • The API has many endpoints with complex cross-cutting concerns (auth, auditing, rate limiting)
  • You need rich automatic model validation with no extra packages
  • The project will be maintained by a large or rotating team
  • You're building a traditional monolith with views and APIs together

Choose Minimal APIs when:

  • You're building microservices or purpose-built APIs with a small number of endpoints
  • Performance and cold start time are measurable concerns (serverless, high-throughput)
  • Your team will commit to consistent organizational patterns (endpoint extension classes)
  • You're prototyping, building internal tools, or writing a proof-of-concept
  • You want simpler unit testing of individual handlers

You can also mix them. ASP.NET Core lets you use both in the same project. A common pattern: use Minimal APIs for simple CRUD endpoints and controllers for complex resource areas with heavy business logic.

// Program.cs
app.MapHealthChecks("/health");             // Minimal API - one-liner, no controller needed
app.MapGet("/api/config", GetPublicConfig); // Minimal API - simple, no logic

// Controllers handle the complex stuff
app.MapControllers();

6 Performance summary

ConcernControllersMinimal APIs
Startup / cold startSlowerFaster
Request throughput~650K req/s~800K req/s
Memory footprintHigherLower
Code per endpointMoreLess
Auto model validationYesNo (manual)
Filter pipelineRicherGood (.NET 7+)
DI syntaxConstructor injectionParameter injection
Default organizationConvention-basedManual
Swagger supportAutomaticManual annotations
Unit testabilityIntegration-styleFunction-level

Final take

There's no objectively wrong choice between Minimal APIs and Controllers for a well-designed application. The .NET team has made it clear both are first-class citizens, and that's not going to change.

The nuanced truth: Controllers are safer as a default because the framework does more organizational work for you. Minimal APIs reward disciplined teams that invest upfront in good structure, and pay dividends in performance and testability.

If you're starting a greenfield project today with a team that's comfortable with the patterns shown above (the endpoint extension class approach, FluentValidation, explicit OpenAPI annotations), Minimal APIs are a great choice and increasingly the direction Microsoft is pointing.

If you're building something that needs to onboard junior developers quickly, or a project where maintainability over 3+ years is the top priority, controllers will protect you from yourself.


What's next?

  • Try it yourself: Create a new project with dotnet new webapi --use-minimal-apis and compare with the default controller template
  • Deep dive into endpoint filters: They're the key to making Minimal APIs production-ready for complex apps
  • Explore .NET 9 OpenAPI improvements: Microsoft.AspNetCore.OpenApi has caught up significantly with Swashbuckle

Got a question about a specific scenario? Drop a comment below and I'll reply.

No comments: