Controllers or Minimal APIs
The same endpoint written as a minimal API and as a controller action, what [ApiController] adds, and when each fits.
app.MapGet("/books/{id:int}", handler) == [HttpGet("{id:int}")] public ActionResult<Book> GetById(int id)ASP.NET Core offers two ways to write an HTTP endpoint. A minimal API endpoint is a route string plus a function, registered with app.MapGet and friends. A controller is a class whose public methods are endpoints; those methods are called actions. Both run on the same routing, the same model binding and the same middleware. Neither is the modern one and the other the legacy one. Both are current in .NET 10.
What [ApiController] adds
[ApiController] is an attribute you put on a controller class. An attribute is a marker that changes how the framework treats the thing it is attached to. This one switches on five behaviours. Attribute routing becomes mandatory, so every action needs its own [HttpGet] or [Route]. Validation failures return 400 with a ValidationProblemDetails body before your action runs. Binding sources are inferred, so a complex parameter is read from the body without writing [FromBody]. A multipart/form-data request content type is inferred for IFormFile and IFormFileCollection parameters. Error status codes you return get a ProblemDetails body automatically.
Choosing
| Minimal API | Controller |
|---|---|
| Less to read per endpoint | Shared setup lives on the class |
| Endpoint filters for cross-cutting work | Action filters, model binders, conventions |
Groups via MapGroup | Groups via the class and [Route] |
| Faster startup, trimming friendly | Shares the pipeline with MVC views |
Pick minimal APIs for a service that only speaks JSON. Pick controllers when you have many endpoints sharing a base class, already render Razor views, or depend on an MVC filter. Mixing both in one app is supported and sometimes the honest answer.
Example
using Microsoft.AspNetCore.Mvc;
var builder = WebApplication.CreateBuilder(args);
// Needed only for the controller half of this file.
builder.Services.AddControllers();
var app = builder.Build();
// Minimal API: route, handler and response in one place.
app.MapGet("/minimal/books/{id:int}", (int id) =>
id == 1 ? Results.Ok(new Book(1, "Dune")) : Results.NotFound());
// Finds every controller class in this assembly and maps its actions.
app.MapControllers();
app.Run();
public record Book(int Id, string Title);
// The same endpoint as a controller action.
[ApiController]
[Route("mvc/books")]
public class BooksController : ControllerBase
{
// [Route] supplies the prefix, [HttpGet] the rest: mvc/books/{id}
[HttpGet("{id:int}")]
[ProducesResponseType<Book>(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public ActionResult<Book> GetById(int id) =>
id == 1 ? new Book(1, "Dune") : NotFound();
}When to use it
- Starting a JSON-only service and deciding whether to create a Controllers folder at all.
- Porting a controller to a minimal API endpoint without changing the URL or the response shape.
- Explaining why a POST returns 400 with a ValidationProblemDetails body that your action never produced.
- Adding one audit log line to every write endpoint in an app that uses minimal APIs.
More examples
A controller with constructor injection and CreatedAtAction
This is what controllers are good at. The store arrives once through the constructor and every action uses it. CreatedAtAction builds the 201 Location header from another action's route, so the URL is never written as a string twice. Reach for a controller when several endpoints share dependencies and a route prefix.
using Microsoft.AspNetCore.Mvc;
namespace Catalog.Controllers;
[ApiController]
[Route("books")]
public class BooksController : ControllerBase
{
private readonly IBookStore _store;
public BooksController(IBookStore store)
{
_store = store;
}
// [FromBody] is inferred for CreateBook because of [ApiController].
[HttpPost]
[ProducesResponseType<Book>(StatusCodes.Status201Created)]
public async Task<ActionResult<Book>> Create(
CreateBook request,
CancellationToken cancellationToken)
{
Book created = await _store.AddAsync(request.Title, cancellationToken);
// Builds the Location header from the GetById action's own route.
return CreatedAtAction(nameof(GetById), new { id = created.Id }, created);
}
[HttpGet("{id:int}", Name = nameof(GetById))]
public async Task<ActionResult<Book>> GetById(
int id,
CancellationToken cancellationToken)
{
Book? found = await _store.FindAsync(id, cancellationToken);
return found is null ? NotFound() : found;
}
}The same group of endpoints as minimal APIs
The dependency is a handler parameter rather than a constructor argument, and MapGroup replaces the [Route] attribute. There is no class, so there is nothing to name and nothing to inherit. Use this shape for a service with a handful of endpoints, or when each endpoint needs a different set of dependencies.
// Program.cs, after var app = builder.Build();
// MapGroup gives every endpoint below the same "/books" prefix.
RouteGroupBuilder books = app.MapGroup("/books");
books.MapGet("/{id:int}", async (int id, IBookStore store, CancellationToken ct) =>
await store.FindAsync(id, ct) is Book found
? Results.Ok(found)
: Results.NotFound());
books.MapPost("/", async (CreateBook request, IBookStore store, CancellationToken ct) =>
{
Book created = await store.AddAsync(request.Title, ct);
return Results.Created($"/books/{created.Id}", created);
});An endpoint filter, the minimal-API answer to an action filter
Controllers have action filters that run around an action. Minimal APIs have endpoint filters, which do the same job: inspect the arguments, either return a result yourself or call next to continue. This is where logging, auditing and ad-hoc checks go when you have no controller to hang an attribute on.
books.MapPost("/", (CreateBook request) => Results.Created("/books/1", request))
.AddEndpointFilter(async (context, next) =>
{
// GetArgument<T>(0) is the first handler parameter.
CreateBook? body = context.GetArgument<CreateBook>(0);
if (string.IsNullOrWhiteSpace(body?.Title))
{
// Short-circuit: the handler never runs.
return Results.ValidationProblem(new Dictionary<string, string[]>
{
["title"] = ["Title is required."]
});
}
return await next(context);
});
Discussion