.NET Programming With Me

Controller vs. Minimal API in .NET 8

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.


1 A quick history first

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.


2 The same task, two ways

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.


3 The real tradeoffs

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.


4 Which one should your team use?

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 WebApplicationFactory setup
  • 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 WebApplicationFactory test 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();

5 Quick reference summary
Concern Controllers Minimal APIs
Ceremony / boilerplateHigher; class, attributes, inheritance requiredLower; delegates and fluent builders
Unit testabilityDirect; instantiate the class in any testIndirect; integration tests preferred
Filters and cross-cuttingMature attribute-based ecosystemSupported via endpoint filters; smaller ecosystem
Organization at scaleConvention enforced by the frameworkSelf-imposed; requires team discipline
OpenAPI / SwaggerFirst-class; attribute-drivenFirst-class; fluent builder-driven
Startup performanceSlightly higher route registration costMarginally leaner startup
Learning curve (MVC background)Low; same model as ASP.NET MVCMedium; new patterns to internalize

Final take

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.

What's next?
  • 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.
Got a question or a scenario I did not cover? Drop a comment below and I will reply.

No comments: