MVC controllers for APIs

ASP.NET Core · Practical · 12 min

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 the Controller suffix.
  • Return ActionResult<T>: either a T (serialized with 200) or helper results like NotFound(), 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

Check yourself

  1. What does [ApiController] do when a required property is missing from the JSON body?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: learn.microsoft.com/en-us/aspnet/core/web-api/