ASP.NET Core controllers - API controller mechanics
This skill owns the shape of a controller-based Web API: how a controller is declared, how routes attach, what an action returns, how parameters bind, and how a cross-cutting concern hangs off an action. It is the mainstream, brownfield-friendly counterpart to dotnet-minimal-api - the same HTTP service, sliced into classes and methods instead of endpoint registrations. It stops at the controller boundary. The pipeline-wide concerns - validation library, OpenAPI document, resilience, observability, caching - live in dotnet-web-backend. The failure-to-ProblemDetails contract and the FluentValidation filter are dotnet-web-error-handling. Auth configuration is dotnet-authentication. Floor is .NET 8 / C# 12; anything newer is marked optional. On .NET Framework 4.8 (MVC 5 / Web API 2) the two separate DI resolvers and the bind-DTOs-not-entities rule are in references/net-framework-48.md.
When to reach for controllers over minimal APIs is a deliberate call - see the decision section at the end. The short version: greenfield prefers minimal APIs; controllers earn their place when an existing codebase, MVC views, or a convention-driven feature (OData, attribute-based API versioning) calls for them.
The controller and the [ApiController] attribute
An API controller is a class deriving from ControllerBase (not Controller - that base drags in view support you do not want on a pure API) and decorated with [ApiController]:
[ApiController]
[Route("api/v1/todos")]
public sealed class TodosController(ITodoService todos) : ControllerBase
{
[HttpGet("{id:guid}")]
public async Task<ActionResult<TodoDto>> GetById(Guid id, CancellationToken ct)
{
var todo = await todos.FindAsync(id, ct);
return todo is null ? NotFound() : Ok(todo.ToDto());
}
}
[ApiController] is the switch that turns a plain MVC controller into an API one. It is opt-in behavior, not cosmetic - it makes attribute routing mandatory, infers binding sources, infers multipart/form-data for IFormFile parameters, maps every error status code to ProblemDetails, and - the one to understand deeply below - triggers an automatic HTTP 400 on a model-validation failure. Apply it per controller, or once on an assembly-level marker so every controller in the project inherits it; do not sprinkle it inconsistently.
Use primary-constructor injection (C# 12) for the controller's collaborators, and accept a CancellationToken as the last parameter of every async action - the framework binds the request-aborted token, and an action that ignores it keeps working after the client has hung up.
Attribute routing and route templates
With [ApiController], routing is attribute-based, not convention-based - there is no MapControllerRoute pattern in play. The route lives on the controller and the action:
[Route("api/v1/todos")]on the controller sets the prefix. Prefer an explicit literal version segment (v1) over the[controller]token - the token couples your URL to the class name and silently breaks the contract on a rename.[HttpGet],[HttpPost],[HttpPut],[HttpPatch],[HttpDelete]on the action carry the verb and the relative template:[HttpGet("{id:guid}")],[HttpPost].- Route constraints belong in the template -
{id:guid},{page:int:min(1)}- so a malformed segment is a 404 before any action code runs, not a parse error inside it. [ApiController]requires attribute routing and will throw at startup if an action has no reachable route, which is the framework catching a wiring mistake early.
app.MapControllers() in Program.cs wires the whole set in one line; the routes themselves stay declared on the classes, so Program.cs is wiring, not a route table.
What an action returns
Three return shapes exist; pick by what the action actually does.
ActionResult<T>is the default for an action with a single success payload plus framework helpers. It beatsIActionResultbecause the implicit cast operators let youreturn dto;(it wraps in anObjectResult) orreturn NotFound();from the same method, and because[ProducesResponseType(StatusCodes.Status200OK)]can omit theType- it is inferred fromT. A nakedIEnumerable<T>does not get the implicit cast (C# has no implicit operators on interfaces), so declareActionResult<IEnumerable<T>>and materialize the sequence.IActionResultonly where there is genuinely no single payload type to name - a download stream, a redirect, a pure status. It carries no payload type for the document, so reach for it rarely.Results<TResult1, TResultN>/TypedResults- the sameHttpResultstypes minimal APIs use, and they work in a controller action. The generic union names every outcome in the signature, the compiler rejects a return path that produces an undeclared one, and the union retains OpenAPI metadata automatically. This is the pick when you want the controller's outcome contract to read like a minimal-API handler:
[HttpPost]
public async Task<Results<Created<TodoDto>, ValidationProblem, Conflict>> Create(
CreateTodoRequest request, CancellationToken ct)
{
var result = await todos.CreateAsync(request, ct);
return TypedResults.Created($"/api/v1/todos/{result.Id}", result.ToDto());
}
TypedResults types come from Microsoft.AspNetCore.Http.HttpResults, not the MVC ControllerBase helpers (Ok, NotFound, CreatedAtAction). Do not mix the two styles within one controller - either lean on the ControllerBase helpers with ActionResult<T>, or commit to TypedResults with the union. Recommendation: ActionResult<T> with the ControllerBase helpers for ordinary brownfield controllers (it is the idiom every MVC reader expects); Results<> only where you are deliberately keeping symmetry with minimal-API handlers or sharing handler code between the two.
Serialize DTOs, never domain entities or EF Core models - a record request and response type at the action edge. Sending an entity leaks the persistence shape, drags lazy-loaded relations into the serializer, and welds the public contract to the schema.
The automatic 400, and keeping it consistent with the house validation filter
This is the section that matters most for the house style. [ApiController] installs ModelStateInvalidFilter, which inspects ModelState immediately before the action body and, if invalid, short-circuits with an automatic HTTP 400 carrying a ValidationProblemDetails body. So if (!ModelState.IsValid) return BadRequest(ModelState); is dead code under [ApiController] - never write it.
The house convention validates with FluentValidation inside a filter (owned by dotnet-web-error-handling), not with data annotations on the DTO. That filter is the single validation authority. The problem: the built-in ModelStateInvalidFilter still fires on any data-annotation or binding ModelState error, so you can end up with two competing 400 shapes - the framework's ValidationProblemDetails and the filter's ProblemDetails - racing for the same failure. Make the FluentValidation filter the only voice by suppressing the built-in filter:
builder.Services.Configure<ApiBehaviorOptions>(options =>
{
options.SuppressModelStateInvalidFilter = true;
});
With it suppressed, the FluentValidation filter runs and produces the one canonical error envelope. The related knobs on ApiBehaviorOptions, for the cases where you keep the built-in path instead:
InvalidModelStateResponseFactory- the delegate that builds the automatic 400. Override it to reshape the body or log the failure; by default it usesProblemDetailsFactoryto emit aValidationProblemDetails.SuppressMapClientErrors- stops[ApiController]from converting bare error status codes (aNotFound()with no body) intoProblemDetails. Leave it off; the mapping is what gives every 4xx/5xx an RFC-shaped body for free.- If you do keep model-state validation on a given action and need a custom 400 that matches the automatic one, call
ValidationProblem()(which returns aValidationProblemDetails), neverBadRequest(...)with an ad-hoc object - that is how the two paths stay shape-consistent.
Do not assemble the error body, the envelope, or the ProblemDetails shape here. That contract is owned by dotnet-web-error-handling; this skill only decides where the validation gate sits and how to stop the framework from competing with it.
Parameter binding sources
[ApiController] infers a binding source per parameter so you usually write none:
- A simple type matching a route token binds from the route; other simple types bind from the query string.
- A complex type not registered in DI binds from the body (
[FromBody]), with at most one body parameter per action. Special framework types -CancellationToken,IFormCollection- are exempt from body inference. - A complex type registered in DI binds from services (
[FromServices]is inferred for[ApiController]parameters). IFormFile/IFormFileCollectioninfermultipart/form-data.
Make the source explicit the moment it is ambiguous or load-bearing: [FromBody], [FromRoute], [FromQuery], [FromHeader], [FromForm], [FromServices], [FromKeyedServices("name")] (.NET 8). An explicit attribute documents intent and stops a refactor from silently moving where a value comes from. To turn off a particular inference globally there are escape hatches - SuppressInferBindingSourcesForParameters and DisableImplicitFromServicesParameters on ApiBehaviorOptions - but prefer an explicit attribute on the one parameter over flipping a global switch.
When an action's parameter list grows long, collect the inputs into one complex-type parameter and mark it with the source - [FromQuery] for a query model - so MVC binds every property from it. That is MVC's own recursive model binding, not the minimal-API [AsParameters] attribute: that one belongs to dotnet-minimal-api and MVC model binding ignores it. A readonly record struct is the allocation-light, immutable carrier:
public readonly record struct ListTodosQuery(int Page, int Size, string? Filter);
[HttpGet]
public async Task<ActionResult<IReadOnlyList<TodoDto>>> List(
[FromQuery] ListTodosQuery query, CancellationToken ct) => ...;
Action filters - the controller's per-action hook
IAsyncActionFilter is the controller analogue of the minimal-API IEndpointFilter: a concern scoped to an action or a controller - argument guards, validation, short-circuiting - that sees the model-bound arguments and can replace the result or call the next stage. Always implement the async interface, never the synchronous IActionFilter; the runtime checks for the async one first, and a synchronous filter that blocks on I/O starves the thread pool. Implement one or the other, not both.
public sealed class ValidationFilter<TRequest> : IAsyncActionFilter
{
public async Task OnActionExecutionAsync(
ActionExecutingContext context, ActionExecutionDelegate next)
{
if (context.ActionArguments.TryGetValue("request", out var arg) && arg is TRequest request)
{
// resolve a validator, validate; on failure set context.Result to short-circuit
}
await next(); // omit this call to short-circuit; the action never runs
}
}
ActionExecutingContext.ActionArguments exposes the bound parameters before the action runs; setting context.Result (or simply not calling next()) short-circuits the pipeline so the action body never executes. The post-action ActionExecutedContext carries Result, Exception, and Canceled for the after side.
Where filters live in the request pipeline, outermost first: authorization filters, then resource filters, then model binding, then action filters, then the action, then result filters; exception filters wrap unhandled action faults. An action filter therefore sees bound arguments but runs inside authorization - it is the wrong place for an auth decision (that is [Authorize] and dotnet-authentication).
Filter ordering is two-dimensional. By default, scope decides: global filters wrap controller filters wrap action filters - so a global filter's before runs first and its after runs last. To override that, implement IOrderedFilter and set Order; a lower Order runs its before-code earlier and its after-code later, and Order always beats scope. Register a filter globally in AddControllers(o => o.Filters.Add<T>()), or attach it as an attribute on a controller or action for narrower scope.
Thin controllers
A controller action is a translation layer, not a place for logic. It binds the request, hands a DTO or its fields to an injected application service, and maps the result to an ActionResult. Business rules, EF Core queries, transactions, and orchestration live in the service - not in the action and never smeared across a filter:
[HttpPost("{id:guid}/complete")]
public async Task<ActionResult<TodoDto>> Complete(Guid id, CancellationToken ct)
{
var result = await todos.CompleteAsync(id, ct);
return result.IsNotFound ? NotFound() : Ok(result.Value.ToDto());
}
A thin action is testable through the service in isolation, keeps the controller readable, and means a second transport (a minimal-API endpoint, a message handler) can call the same service without duplicating logic. The architecture that organizes those services - VSA, clean, layered - is chosen once per project in dotnet-web-backend, never inside the controller.
ProblemDetails
Errors leave a controller as RFC-shaped ProblemDetails. [ApiController] already maps bare error status codes to it, and the ControllerBase.Problem(...) / ValidationProblem(...) helpers produce it explicitly. The global exception handler, the envelope shape, the status-code mapping, and the FluentValidation filter that turns validation failures into ValidationProblemDetails are all owned by dotnet-web-error-handling - reuse that contract, do not restate or re-assemble it here. This skill only points the error path at it.
Controllers or minimal APIs - the decision
Both produce the same HTTP service; the choice is about fit, not capability.
- Greenfield, default to minimal APIs (
dotnet-minimal-api). Less ceremony, the endpoint contract reads in one place,TypedResultsandResults<>are first-class, and the per-endpoint filter model is lighter than the filter pipeline. For a new service with no constraint pulling the other way, that is the recommendation. - Reach for controllers when something concrete calls for them:
- An existing controller-based codebase - match it exactly. A repo with both styles has neither; the established pattern wins (the architecture rule in
dotnet-web-backend). - MVC views or Razor alongside the API - the controller already exists for the view; the API actions belong on the same base.
- A feature whose tooling is convention-bound to controllers - OData, or an attribute-and-convention API-versioning setup that targets controllers and actions.
- A team or codebase standard that mandates the controller idiom - the filter pipeline,
[ApiController]inference, and per-action attributes are familiar ground for an MVC-trained team.
- An existing controller-based codebase - match it exactly. A repo with both styles has neither; the established pattern wins (the architecture rule in
Do not run a third pattern in one repo to get one feature. If the bulk is minimal APIs and one slice needs OData, that is a real reason to add controllers there - a deliberate, scoped exception, not a free-for-all.
Anti-patterns
- Business logic, EF Core queries, or a
try/catchin the action body. Delegate to a service and let the global exception handler own the failure path - perdotnet-web-error-handling. - A domain entity or EF model serialized straight to the client, or a mutable request
classwhere arecordbelongs. - Re-implementing validation, the error envelope, the OpenAPI document, or auth here - each is owned by a companion skill.
- Two HTTP styles (controllers and minimal APIs) interleaved in one repo with no boundary; pick one as the default and confine the other to its justified slice.
Newer versions (optional)
Targeting .NET 9 or 10? The optional deltas - the built-in OpenAPI generator, the unified validation APIs, the IActionContextAccessor obsoletion - are in references/newer-versions.md.