.NET Minimal APIs vs Controllers: When to Use Each

.NET 6 introduced Minimal APIs as an alternative to the traditional Controller-based approach. Minimal APIs reduce ceremony and compile faster but sacrifice some structure. This post compares both app

Introduction#

.NET 6 introduced Minimal APIs as an alternative to the traditional Controller-based approach. Minimal APIs reduce ceremony and compile faster but sacrifice some structure. This post compares both approaches and provides guidance on when each is appropriate.

Minimal APIs#

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
// Program.cs — a complete API
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDbContext<AppDbContext>(opt =>
    opt.UseNpgsql(builder.Configuration.GetConnectionString("Default")));
builder.Services.AddScoped<IOrderService, OrderService>();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();

var app = builder.Build();
app.UseSwagger();
app.UseSwaggerUI();

// Route group with shared prefix and middleware
var orders = app.MapGroup("/api/v1/orders")
    .RequireAuthorization()
    .WithTags("Orders");

orders.MapGet("/", async (IOrderService svc, CancellationToken ct) =>
    Results.Ok(await svc.GetAllAsync(ct)));

orders.MapGet("/{id:int}", async (int id, IOrderService svc, CancellationToken ct) =>
{
    var order = await svc.GetByIdAsync(id, ct);
    return order is null ? Results.NotFound() : Results.Ok(order);
});

orders.MapPost("/", async (
    CreateOrderRequest request,
    IOrderService svc,
    CancellationToken ct) =>
{
    var order = await svc.CreateAsync(request, ct);
    return Results.Created($"/api/v1/orders/{order.Id}", order);
}).WithValidator<CreateOrderRequest>();  // FluentValidation integration

orders.MapDelete("/{id:int}", async (int id, IOrderService svc, CancellationToken ct) =>
{
    await svc.DeleteAsync(id, ct);
    return Results.NoContent();
}).RequireAuthorization("admin-policy");

app.Run();

Controller-Based APIs#

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
// OrdersController.cs
[ApiController]
[Route("api/v1/[controller]")]
[Authorize]
public class OrdersController : ControllerBase
{
    private readonly IOrderService _svc;

    public OrdersController(IOrderService svc) => _svc = svc;

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

    [HttpGet("{id:int}")]
    [ProducesResponseType<Order>(StatusCodes.Status200OK)]
    [ProducesResponseType(StatusCodes.Status404NotFound)]
    public async Task<IActionResult> GetById(int id, CancellationToken ct)
    {
        var order = await _svc.GetByIdAsync(id, ct);
        return order is null ? NotFound() : Ok(order);
    }

    [HttpPost]
    public async Task<IActionResult> Create(
        [FromBody] CreateOrderRequest request,
        CancellationToken ct)
    {
        var order = await _svc.CreateAsync(request, ct);
        return CreatedAtAction(nameof(GetById), new { id = order.Id }, order);
    }

    [HttpDelete("{id:int}")]
    [Authorize(Policy = "admin-policy")]
    public async Task<IActionResult> Delete(int id, CancellationToken ct)
    {
        await _svc.DeleteAsync(id, ct);
        return NoContent();
    }
}

Organizing Minimal APIs for Larger Projects#

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
// IEndpoint pattern: keeps endpoints organized without controllers
public interface IEndpoint
{
    void MapEndpoints(IEndpointRouteBuilder app);
}

// OrderEndpoints.cs
public class OrderEndpoints : IEndpoint
{
    public void MapEndpoints(IEndpointRouteBuilder app)
    {
        var group = app.MapGroup("/api/v1/orders")
            .RequireAuthorization()
            .WithTags("Orders");

        group.MapGet("/", GetAll);
        group.MapGet("/{id:int}", GetById);
        group.MapPost("/", Create);
    }

    static async Task<IResult> GetAll(IOrderService svc, CancellationToken ct) =>
        Results.Ok(await svc.GetAllAsync(ct));

    static async Task<IResult> GetById(int id, IOrderService svc, CancellationToken ct)
    {
        var order = await svc.GetByIdAsync(id, ct);
        return order is null ? Results.NotFound() : Results.Ok(order);
    }

    static async Task<IResult> Create(
        CreateOrderRequest request,
        IOrderService svc,
        CancellationToken ct)
    {
        var order = await svc.CreateAsync(request, ct);
        return Results.Created($"/api/v1/orders/{order.Id}", order);
    }
}

// Program.cs: auto-register all IEndpoint implementations
var endpoints = typeof(Program).Assembly
    .GetTypes()
    .Where(t => t.IsAssignableTo(typeof(IEndpoint)) && !t.IsAbstract)
    .Select(Activator.CreateInstance)
    .Cast<IEndpoint>();

foreach (var endpoint in endpoints)
    endpoint.MapEndpoints(app);

Filters and Middleware#

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// Minimal API endpoint filter (validation, logging)
app.MapPost("/api/v1/orders", CreateOrder)
    .AddEndpointFilter(async (context, next) =>
    {
        var request = context.GetArgument<CreateOrderRequest>(0);
        if (request.Total <= 0)
            return Results.ValidationProblem(new Dictionary<string, string[]>
            {
                ["Total"] = ["Total must be positive"]
            });

        return await next(context);
    });

// Controller: action filter
public class ValidationFilter : IActionFilter
{
    public void OnActionExecuting(ActionExecutingContext context)
    {
        if (!context.ModelState.IsValid)
            context.Result = new UnprocessableEntityObjectResult(
                context.ModelState);
    }

    public void OnActionExecuted(ActionExecutedContext context) { }
}

Decision Guide#

Factor Minimal APIs Controllers
Code volume Less boilerplate More structure
Compile time Faster Slower
API versioning Manual grouping Built-in ApiVersion
Large teams Harder to structure Easier conventions
Existing codebase May not mix well Standard approach
Action filters Endpoint filters Full filter pipeline
OpenAPI Full support Full support

Use Minimal APIs for: new microservices, small APIs, teams comfortable with functional style.

Use Controllers for: large APIs with many endpoints, teams with existing conventions, when you need the full filter pipeline.

Conclusion#

Minimal APIs reduce boilerplate and are the direction ASP.NET Core is moving. For new projects, they work well with the IEndpoint pattern for organization. Controllers remain valid for large, complex APIs where the additional structure pays for itself. Both have full OpenAPI/Swagger support and equivalent performance.

Contents