If you have started a new ASP.NET Core project in the last year, you have almost certainly paused at the same fork: controllers or Minimal APIs? In .NET 8 both approaches are mature, well-documented, and production-ready. They share the same middleware pipeline, the same dependency injection container, and they produce identical HTTP responses on the wire. What differs is the programming model, the amount of ceremony, and how well each scales as your team and codebase grow.
Controller-based APIs and Minimal APIs are the two official routing models in ASP.NET Core today. Both get you to a working endpoint; the question this post answers is which model fits your project size, your team's background, and your long-term maintenance plan.
You will walk away with runnable side-by-side examples, an honest breakdown of five real tradeoffs, and a summary table suitable for your team's architecture decision record.
Controller-based APIs trace their lineage to ASP.NET MVC, which shipped in 2009 and was rebuilt as ASP.NET Core from the ground up in 2016. The model is object-oriented: a class annotated with [ApiController] groups related endpoints, and the framework uses attribute routing, convention-based model binding, and action filters to wire everything together.
Minimal APIs arrived in .NET 6 as a deliberate reaction to the ceremony that controllers introduced for small or service-oriented scenarios. Instead of classes and attributes, you call app.MapGet, app.MapPost, and their siblings directly on the WebApplication instance or in extension methods. The model is functional: a route maps to a delegate.
Critically, both models compile to the same underlying endpoint routing infrastructure introduced in ASP.NET Core 3. There is no meaningful difference in the HTTP contract they produce; every observable difference lives in how you write and organize your code.
To ground the comparison, here is a GET /api/products/{id} endpoint backed by an injected service, written both ways. Both examples handle cancellation, declare response types for OpenAPI, and return a 404 when the product is not found.
Controller-based approach
// Controllers/ProductsController.cs
[ApiController]
[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
private readonly IProductService _service;
public ProductsController(IProductService service)
=> _service = service;
[HttpGet("{id:int}")]
[ProducesResponseType(typeof(ProductDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetById(int id, CancellationToken ct)
{
var product = await _service.GetByIdAsync(id, ct);
return product is null ? NotFound() : Ok(product);
}
}
Minimal API approach
// Endpoints/ProductEndpoints.cs
public static class ProductEndpoints
{
public static IEndpointRouteBuilder MapProductEndpoints(
this IEndpointRouteBuilder routes)
{
var group = routes.MapGroup("/api/products")
.WithTags("Products");
group.MapGet("/{id:int}", async (
int id,
IProductService service,
CancellationToken ct) =>
{
var product = await service.GetByIdAsync(id, ct);
return product is null ? Results.NotFound() : Results.Ok(product);
})
.Produces<ProductDto>()
.ProducesProblem(StatusCodes.Status404NotFound);
return routes;
}
}
Notice that the Minimal API version uses MapGroup to avoid repeating the base path and is placed in a dedicated static class rather than Program.cs. That is the idiomatic .NET 8 pattern for organizing Minimal API endpoints at any meaningful scale. Dumping all MapGet calls into Program.cs works for demos but does not survive a real API.
1. Boilerplate and ceremony
Controllers require inheriting from ControllerBase, marking the class with [ApiController], and defining a class per resource group. For a 30-endpoint API this structure is a feature; it is predictable and easy to navigate. For a three-endpoint microservice it feels like architecture for architecture's sake. Minimal APIs strip the class and attributes away, replacing them with terse lambda handlers and fluent builder calls.
Does this matter to you? If your API has more than ten distinct route templates or your team rotates developers frequently, the controller convention repays its upfront cost. If you are building a self-contained service that does one thing well, the Minimal API ceremony saving is real.
2. Testability
Controllers are plain classes. You can instantiate them in a unit test, inject a mock, and call action methods directly without spinning up an HTTP server.
// Direct unit test of a controller action
[Fact]
public async Task GetById_ReturnsNotFound_WhenProductMissing()
{
var mockService = new Mock<IProductService>();
mockService.Setup(s => s.GetByIdAsync(99, default))
.ReturnsAsync((ProductDto?)null);
var controller = new ProductsController(mockService.Object);
var result = await controller.GetById(99, default);
Assert.IsType<NotFoundResult>(result);
}
Minimal API handlers are delegates rather than public methods on an instantiable class, so the same pattern is not available. The canonical testing approach is WebApplicationFactory<T> integration tests, which are valuable but carry more setup weight per test. Teams that want fast, isolated unit tests close to the HTTP boundary will find controllers more accommodating.
3. Cross-cutting concerns
Controllers support action filters, result filters, and exception filters applied via attributes or registered globally through MvcOptions. This is a mature, well-understood model for concerns like audit logging, caching headers, and conditional response transformation.
// Attribute-applied action filter on a controller action
[ServiceFilter(typeof(AuditLogFilter))]
[HttpPost]
public async Task<IActionResult> Create([FromBody] CreateProductRequest request,
CancellationToken ct) { ... }
Minimal APIs support endpoint filters via .AddEndpointFilter<T>() and .AddEndpointFilterFactory(), which covers most of the same ground. The ecosystem of pre-built filters is smaller, however, and the model is less familiar to teams coming from an MVC background. For application-wide concerns such as exception handling and authentication, middleware applies equally to both models.
4. Code organization at scale
As an API grows past a handful of routes, the structural question becomes more important than the syntactic one. Controllers encode the convention: one class per resource, one action per HTTP verb. New developers know exactly where to look. With Minimal APIs the structure is self-imposed; MapGroup combined with one static extension method per feature area is the current community standard, but enforcing it consistently across a team requires deliberate decision-making that controllers handle for you automatically.
5. OpenAPI support
In .NET 8 both models have first-class OpenAPI support. The built-in Microsoft.AspNetCore.OpenApi package handles document generation for either model; controllers use [ProducesResponseType] attributes while Minimal APIs use the fluent .Produces<T>() builder methods. Neither approach produces materially better documentation. Teams already invested in Swashbuckle will find the controller attribute style familiar, but the Minimal API fluent style is equally expressive.
The right choice depends on project scope and team dynamics more than on any benchmark. Here is a practical decision guide.
Choose Controllers when:
- Your team has existing ASP.NET Core MVC experience and the controller pattern is already second nature
- The API has many endpoints across multiple resource types and you need the framework to enforce organizational conventions
- You rely on action filters for cross-cutting concerns and want attribute-based application
- You want to unit-test action methods directly without a full
WebApplicationFactorysetup - You are extending or maintaining an existing controller-based codebase
Choose Minimal APIs when:
- You are building a microservice or a focused API with a small, stable route surface
- Your team is comfortable defining its own organizational structure and does not need the framework to enforce it
- You prefer integration tests over action-level unit tests and already have a
WebApplicationFactorytest project in place - Startup latency matters, such as in Azure Functions or container workloads with fast cold-start requirements
- You want to minimize the number of abstractions new team members need to learn
Mixing both in a single application is also a legitimate pattern. A common approach is using controllers for the primary resource API while using Minimal APIs for lightweight utility endpoints.
// Program.cs — valid mixed configuration
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.AddScoped<IProductService, ProductService>();
var app = builder.Build();
app.MapControllers(); // controller-based routes
app.MapHealthChecks("/health"); // lightweight utility endpoint
app.MapGet("/version", () => Results.Ok(new // simple info endpoint
{
Version = "2.1.0",
Runtime = Environment.Version.ToString()
}));
app.Run();
| Concern | Controllers | Minimal APIs |
|---|---|---|
| Ceremony / boilerplate | Higher; class, attributes, inheritance required | Lower; delegates and fluent builders |
| Unit testability | Direct; instantiate the class in any test | Indirect; integration tests preferred |
| Filters and cross-cutting | Mature attribute-based ecosystem | Supported via endpoint filters; smaller ecosystem |
| Organization at scale | Convention enforced by the framework | Self-imposed; requires team discipline |
| OpenAPI / Swagger | First-class; attribute-driven | First-class; fluent builder-driven |
| Startup performance | Slightly higher route registration cost | Marginally leaner startup |
| Learning curve (MVC background) | Low; same model as ASP.NET MVC | Medium; new patterns to internalize |
For most teams building a multi-resource API with five or more controllers' worth of endpoints, controllers remain the pragmatic default. The conventions are well understood, the tooling is battle-tested, and onboarding a new developer into a controller-based project requires almost no orientation. Minimal APIs earn their place in focused microservices and internal tools where the reduction in ceremony translates directly into a codebase that is faster to read and easier to keep lean.
Lean toward controllers when you are building a product API that will grow. Lean toward Minimal APIs when you are building a service that does one thing and should stay small.
- Project structure: The next post in this series covers how to organize folders, layers, and naming conventions in a .NET 8 Web API so the codebase stays navigable as it grows.
- Validation strategy: Once your routing model is settled, the next meaningful decision is FluentValidation versus Data Annotations, covered later in this series.
- Official reference: The ASP.NET Core Minimal APIs comparison docs at learn.microsoft.com are kept up to date with each .NET release and are worth bookmarking.
No comments: