BrandGhost
Minimal API vs Controllers in ASP.NET Core: Which Should You Use?

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

The minimal api vs controllers debate is one of the most common questions developers face when starting a new ASP.NET Core project. It's a surprisingly nuanced decision, and the answer genuinely depends on your team, your domain, and how much ceremony you want in your codebase. Controllers have been the default approach since ASP.NET MVC, carrying years of conventions and tooling behind them. Minimal APIs arrived in .NET 6, offering a lighter, more functional syntax that strips away a lot of the boilerplate. In .NET 8 and .NET 10, minimal APIs have matured considerably -- the feature gap has narrowed, and neither approach is clearly "better" in every situation.

This article walks through both approaches in detail. You'll see code examples side by side, understand where each one shines and struggles, and come away with a clear mental model for making the decision on your next project.

What Are Minimal APIs?

Minimal APIs let you define HTTP endpoints directly in Program.cs (or any method you delegate to) using extension methods like MapGet, MapPost, MapPut, and MapDelete. The handler is either a lambda or a named method group, and parameters are bound automatically from the route, query string, body, or services. There's no controller class, no base class to inherit from, and no attribute-heavy infrastructure in the way.

Route groups (RouteGroupBuilder) help you organize related endpoints under a shared prefix and apply shared middleware or filters without duplication. Filters in minimal APIs work through the IEndpointFilter interface, which gives you a single pipeline-style hook for cross-cutting concerns. It's simpler than the full controller filter pipeline, which is both a blessing and a limitation depending on what you need.

The draw of minimal APIs is clarity and speed. For a microservice handling a small surface area, or for a team that prefers a more functional style, minimal APIs feel natural and get out of your way fast.

// Minimal API CRUD for a Products resource in .NET 10
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddScoped<IProductService, ProductService>();

var app = builder.Build();

var products = app.MapGroup("/api/products").WithTags("Products");

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

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

products.MapPost("/", async (CreateProductRequest request, IProductService svc) =>
{
    var created = await svc.CreateAsync(request);
    return Results.CreatedAtRoute("GetProduct", new { id = created.Id }, created);
});

products.MapPut("/{id:int}", async (int id, UpdateProductRequest request, IProductService svc) =>
{
    var updated = await svc.UpdateAsync(id, request);
    return updated is null ? Results.NotFound() : Results.Ok(updated);
});

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

app.Run();

This is concise. The handler parameters are injected automatically -- services come from the DI container, route values bind by name, and the body deserializes from JSON. There's no ceremony around class structure.

What Are Controllers?

Controllers are the traditional ASP.NET Core approach, inherited from ASP.NET MVC. You create a class that inherits from ControllerBase, decorate it with [ApiController] and a [Route] attribute, and define action methods that map to HTTP verbs through [HttpGet], [HttpPost], and so on. The [ApiController] attribute activates a set of conventions including automatic model validation, automatic 400 responses for bad input, and binding source inference.

Controllers enforce a class-based structure that naturally groups related endpoints. This pays dividends on larger projects where dozens of endpoints need to live somewhere organized. The full filter pipeline -- IActionFilter, IExceptionFilter, IResourceFilter, IAuthorizationFilter -- gives you fine-grained control over request lifecycle that minimal APIs can't fully replicate.

// Controller CRUD for a Products resource in .NET 10
[ApiController]
[Route("api/[controller]")]
public sealed class ProductsController : ControllerBase
{
    private readonly IProductService _productService;

    public ProductsController(IProductService productService)
    {
        _productService = productService;
    }

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

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

    [HttpPost]
    public async Task<IActionResult> Create(CreateProductRequest request)
    {
        var created = await _productService.CreateAsync(request);
        return CreatedAtRoute("GetProduct", new { id = created.Id }, created);
    }

    [HttpPut("{id:int}")]
    public async Task<IActionResult> Update(int id, UpdateProductRequest request)
    {
        var updated = await _productService.UpdateAsync(id, request);
        return updated is null ? NotFound() : Ok(updated);
    }

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

The logic is nearly identical. The controller approach is more verbose, but the structure is explicit -- a developer who has never seen this codebase before will immediately understand what's going on.

Side-by-Side Comparison

Both approaches cover the same ground, but they differ in important ways across several dimensions:

Feature Minimal API Controllers
Syntax complexity Low -- lambdas and method groups Higher -- class hierarchy and attributes
DI support Parameter injection or RequestServices Constructor injection
Filter support IEndpointFilter (single pipeline) Full filter pipeline (Action, Exception, Resource, Authorization)
Testing ergonomics Simple -- WebApplicationFactory works cleanly Good -- same factory; controller unit tests also possible
OpenAPI generation First-class in .NET 9+ (built-in Microsoft.AspNetCore.OpenApi) First-class via Swashbuckle/NSwag (third-party, not part of the framework)
Code organization at scale Requires discipline; route groups help Naturally enforced by class structure
Middleware integration Full pipeline access Full pipeline access
Performance Slightly lower startup overhead, AOT-friendly Slightly higher overhead from reflection
Learning curve Low for functional-style developers Low for OOP-oriented developers

The table makes it look like controllers are more capable. That's true in a few specific areas. But for the majority of APIs, those extra capabilities are not exercised at all, and the simplicity of minimal APIs wins. For most APIs this performance difference is not the primary deciding factor -- choose based on structure and maintainability.

Dependency Injection: Both Work, Slightly Differently

Dependency injection is a first-class citizen in both approaches. Controllers receive services through constructor injection, which is the standard pattern. The DI container builds the controller instance on each request and injects registered services automatically.

Minimal APIs primarily use parameter injection directly in handlers. When logic moves into dedicated service types or handler classes, standard constructor injection applies as normal. This is arguably more explicit: you see exactly what a given endpoint depends on right in its signature.

// DI comparison

// Controller -- constructor injection
public sealed class OrdersController : ControllerBase
{
    private readonly IOrderService _orderService;
    private readonly ILogger<OrdersController> _logger;

    public OrdersController(IOrderService orderService, ILogger<OrdersController> logger)
    {
        _orderService = orderService;
        _logger = logger;
    }

    [HttpGet("{id:int}")]
    public async Task<IActionResult> Get(int id)
    {
        _logger.LogInformation("Fetching order {OrderId}", id);
        var order = await _orderService.GetAsync(id);
        return order is null ? NotFound() : Ok(order);
    }
}

// Minimal API -- parameter injection
app.MapGet("/api/orders/{id:int}", async (
    int id,
    IOrderService orderService,
    ILogger<Program> logger) =>
{
    logger.LogInformation("Fetching order {OrderId}", id);
    var order = await orderService.GetAsync(id);
    return order is null ? Results.NotFound() : Results.Ok(order);
});

Both work. The parameter injection approach in minimal APIs can feel cluttered if a handler needs four or five services. At that point, you might prefer the class-based approach -- or you might refactor to a method group pointing to a service class, which keeps minimal API syntax but moves the implementation out of Program.cs.

If you're designing systems that rely heavily on DI patterns, it's worth reading more about how DI containers use reflection internally -- it gives useful context for understanding the performance implications of either approach.

Testing Ergonomics

Testing is an area where some developers find a slight simplicity advantage with minimal APIs. WebApplicationFactory<TProgram> works for both approaches and lets you spin up the full application in-process for integration testing. You make real HTTP calls against your endpoints without needing a running server, and the test response contains the full response including status codes and headers.

For controllers specifically, you can also write unit tests that instantiate the controller directly, mock its dependencies, and call action methods as plain C# methods. This can be faster for unit-testing complex logic without going through the full HTTP pipeline. Minimal APIs don't have this option for lambda handlers -- you need to go through WebApplicationFactory or extract the logic into a service class.

The practical difference is small. Integration tests through WebApplicationFactory are the recommended approach for API testing in both styles, and those tests look identical regardless of whether you're testing a minimal API or a controller.

Filter Support: A Real Difference

This is one area where controllers genuinely offer more. The ASP.NET Core filter pipeline for controllers includes five filter types: Authorization filters, Resource filters, Action filters, Exception filters, and Result filters. Each runs at a specific point in the request lifecycle and can short-circuit the pipeline. This gives you very fine-grained control that's well-understood and broadly documented.

Minimal APIs have IEndpointFilter, which provides a single before/after hook around handler execution. You can chain multiple filters, but they all run in the same position in the pipeline -- before the handler executes and after it completes. You cannot replicate the exact semantics of a Resource filter or an Exception filter with IEndpointFilter.

For most APIs, IEndpointFilter is sufficient. Logging, validation, and response shaping all work well through it. If your team relies heavily on custom action filters or exception filters for cross-cutting concerns, controllers will feel more complete.

Code Organization at Scale

This is the honest trade-off. Minimal APIs scale beautifully to small and medium APIs when you organize them thoughtfully. Route groups let you cluster related endpoints, apply shared authorization policies, and add common middleware at the group level. If you split endpoints into extension methods by feature -- a ProductEndpoints.cs file, an OrderEndpoints.cs file, each registering its own route group -- the code stays clean.

Without that discipline, Program.cs becomes a wall of lambda functions. That happens in real projects. Controllers avoid this problem structurally: each controller file is naturally bounded, class-level attributes apply to all actions, and the framework enforces a consistent shape.

Thinking about larger-scale organization? The concepts behind modular monolith architecture in C# apply here -- whether you use controllers or minimal APIs, the real win comes from organizing code by feature, not by technical layer.

.NET 10 Minimal API Improvements

.NET 10 continues the investment in minimal APIs that started in .NET 8. OpenAPI support is now first-class through the built-in Microsoft.AspNetCore.OpenApi package (supported in .NET 10, with built-in OpenAPI support first arriving in .NET 9), with TypedResults making return type inference reliable and accurate. Filters are better documented and more consistent in behavior.

Problem details support through TypedResults.Problem and TypedResults.ValidationProblem gives minimal APIs parity with the automatic 400 handling that [ApiController] provides for controllers. Native AOT compatibility has improved significantly, meaning minimal APIs are now a legitimate choice for high-performance, trimmed deployments where startup time matters.

The pattern is usually clear: with each release, the feature gap between minimal APIs and controllers narrows. .NET 10 has narrowed the gap significantly, reducing many of the previous reasons to avoid minimal APIs for new projects -- though team familiarity, project scale, and organizational conventions still matter.

Decision Matrix: When to Choose Each

The honest answer is that neither approach is universally superior. The better fit often depends on your project's context, your team's experience, and your priorities.

Choose Minimal APIs when:

  • You're building a microservice with a focused, limited surface area
  • Your team is comfortable with functional-style programming and prefers less boilerplate
  • You need AOT compilation for startup time or deployment size
  • You're prototyping or building an internal API where structure matters less
  • You want to avoid the cognitive overhead of class hierarchies and attribute stacks

Choose Controllers when:

  • You have a large team with varied .NET experience -- the class structure is familiar to most developers
  • Your domain is complex and you need the full filter pipeline for cross-cutting concerns
  • You're working in a brownfield project that already uses controllers
  • Your team follows strong OOP conventions and prefers explicit class-based architecture
  • You need the full set of [ApiController] behaviors without reimplementing them

One underrated option: you can mix both in the same application. A single ASP.NET Core application can have controller-based endpoints and minimal API endpoints coexisting. This lets you use minimal APIs for new endpoints while leaving existing controllers in place -- a pragmatic approach for greenfield features in brownfield projects.

Architecture decisions at this level often connect to broader system design questions. If you're thinking about how the API layer fits into your overall architecture, the complete guide to monolith architecture in C# provides solid grounding for those decisions.

Design patterns matter here too. The mediator design pattern pairs naturally with both approaches and is especially useful for keeping controller or endpoint logic thin by delegating to handlers.

Conclusion

The minimal api vs controllers choice is not a matter of one being right and the other being wrong. It's a matter of fit. Minimal APIs offer simplicity, lower overhead, and a more functional style. Controllers offer structure, a richer filter pipeline, and conventions that large teams rely on. In .NET 10, both are mature, well-supported, and capable of building production-grade APIs.

If you're starting a new project today, consider the size of your team, the complexity of your domain, and whether you value brevity or explicit structure more. When in doubt, minimal APIs are a reasonable default for focused services, and controllers remain the safe choice for complex, team-maintained applications.


Frequently Asked Questions

Can you mix minimal APIs and controllers in the same ASP.NET Core project?

Yes -- this is a fully supported configuration. ASP.NET Core routing handles both styles without conflict, and you can register AddControllers() alongside your minimal API endpoint registrations without any issues. The two coexist in the same middleware pipeline.

This is particularly useful during migrations. You can introduce minimal APIs for new endpoints while leaving existing controller-based endpoints untouched. Over time, you can migrate controller endpoints to minimal APIs incrementally -- or just leave the mix in place if it works for your team.

Do minimal APIs perform better than controllers?

The performance difference is real but small for most workloads. Minimal APIs have a slightly lower startup overhead because they skip some of the reflection-heavy controller discovery that happens at app startup. In AOT-compiled scenarios, minimal APIs have a meaningful advantage because the controller pipeline's reliance on reflection is harder to trim.

For steady-state throughput -- requests per second on a warm application -- the difference is negligible in most benchmarks. Both approaches are fast enough that the bottleneck is almost always the database or an external service, not the framework routing overhead.

Do minimal APIs support model validation like [ApiController] does?

Not automatically. The [ApiController] attribute on controllers provides automatic model validation -- if ModelState.IsValid is false, a 400 response is returned before your action even runs. Minimal APIs don't have this built in.

You can replicate it through IEndpointFilter and a validation library like FluentValidation (third-party, not part of the framework). .NET 10 also includes improved built-in validation helpers for minimal APIs through TypedResults. It's not quite as automatic as the controller behavior, but it's achievable without much ceremony.

Can minimal APIs use attributes like [Authorize] and [AllowAnonymous]?

Minimal API endpoints support authorization, but the syntax is different. Instead of attributes on the handler, you call .RequireAuthorization() and .AllowAnonymous() as extension methods on the endpoint or route group builder. Functionally, the result is the same -- the authorization middleware applies the same checks.

For example, products.MapGet("/", handler).RequireAuthorization("PolicyName") applies the named policy to that endpoint. Applied at the group level, it covers all endpoints in the group at once. This is arguably cleaner than decorating every action method individually.

How does OpenAPI/Swagger work with minimal APIs in .NET 10?

Both .NET 9 and .NET 10 ship with first-class OpenAPI support through Microsoft.AspNetCore.OpenApi. You call builder.Services.AddOpenApi() and app.MapOpenApi() in Program.cs, and the framework generates an OpenAPI document from your endpoint definitions. Return types inferred from TypedResults are included in the schema automatically.

For controllers, Swashbuckle and NSwag remain the dominant tools, though the built-in Microsoft.AspNetCore.OpenApi package also works for controllers. The minimal API story has improved considerably -- in .NET 10, you no longer need to add Produces<T>() calls manually when you use TypedResults return types.

Is it hard to add logging to minimal API endpoints?

Logging works the same way -- you inject ILogger<T> or ILogger as a parameter into your endpoint handler, or you inject it into a service class that the endpoint delegates to. The ILogger abstraction doesn't care whether it's being used in a controller or a lambda handler.

If you're setting up structured logging for your application, configuring Serilog in ASP.NET Core covers the full setup process. The configuration is identical regardless of whether you're using minimal APIs or controllers -- logging middleware sits at the application level, not the endpoint level.

Should I use route groups or separate files for organizing minimal API endpoints?

Use both. Route groups provide the prefix and shared configuration -- authorization, rate limiting, tags for OpenAPI. Separate files provide the organizational structure. The common pattern is to create an extension method per feature area that accepts a RouteGroupBuilder or WebApplication and registers its endpoints, then call each extension method from Program.cs.

This approach keeps Program.cs clean and gives each feature its own file without adding any framework complexity. It scales well to medium-sized APIs and is easy for new team members to navigate.

ASP.NET Core Controllers: A Practical Guide to Building REST Endpoints

Learn how to build production-ready REST endpoints with ASP.NET Core controllers -- action results, DI, model binding, and .NET 10 best practices explained.

ASP.NET Core with Needlr: Simplified Web Application Setup

Learn how to set up ASP.NET Core web applications with Needlr for dependency injection, including ForWebApplication, minimal APIs, and middleware configuration.

ASP.NET Core Web API in .NET: The Complete Guide

Master ASP.NET Core Web API in .NET 10 -- learn request pipelines, routing, controllers, JWT authentication, error handling, and deployment strategies.

An error has occurred. This application may no longer respond until reloaded. Reload