Many existing codebases and teams use controllers; you need to read and write both styles.
Controllers are the classic alternative to minimal APIs. A controller is a class deriving from ControllerBase, with action methods mapped by attributes:
[ApiController]
[Route("api/[controller]")]
public class TodosController(ITodoStore store) : ControllerBase[HttpGet("{id}")],[HttpPost],[HttpPut],[HttpDelete]map actions;[controller]is replaced with the class name without theControllersuffix.- Return
ActionResult<T>: either aT(serialized with 200) or helper results likeNotFound(),CreatedAtAction(...),NoContent(). - Dependencies arrive via the constructor (a primary constructor is concise).
[ApiController] adds conventions: complex parameters bind from the body without [FromBody], and an invalid model (failed DataAnnotations such as [Required], [Range], or a bad JSON body) automatically returns a 400 ValidationProblemDetails before the action runs.
Register with builder.Services.AddControllers() and app.MapControllers(). Controllers and minimal APIs can coexist in one app.
Which to choose? Minimal APIs are leaner, faster to start and support Native AOT; controllers offer a structured convention with filters, model binding providers and familiar organization for large teams. New projects increasingly use minimal APIs organized with route groups and extension methods, but the concepts (routing, binding, results, DI) are the same.
Example
using System.ComponentModel.DataAnnotations;
using Microsoft.AspNetCore.Mvc;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.AddSingleton<List<Todo>>();
var app = builder.Build();
app.MapControllers();
app.Run();
public record Todo(int Id, [Required, StringLength(50)] string Title);
[ApiController, Route("api/[controller]")] // -> /api/todos
public class TodosController(List<Todo> todos) : ControllerBase
{
[HttpGet("{id:int}")]
public ActionResult<Todo> Get(int id) =>
todos.Find(t => t.Id == id) is { } t ? t : NotFound();
[HttpPost]
public ActionResult<Todo> Create(Todo todo) // body + validation automatic
{
todos.Add(todo);
return CreatedAtAction(nameof(Get), new { id = todo.Id }, todo);
}
} // POST /api/todos {"id":1} -> 400, errors: {"Title":["The Title field is required."]}Remember
- Controllers derive from ControllerBase and use attribute routing
- ActionResult<T> returns either T or a status result
- [ApiController] infers body binding and returns 400 on invalid models
- AddControllers + MapControllers wire them up