Controllers and Actions
By the end of this lesson
Organise endpoints into controllers and return appropriate results.
A controller is a class that groups endpoints belonging together. For the employees API, one controller owns everything under /api/employees, and each public method is one endpoint.
Its job is narrow: read the request, hand the work to something that understands the domain, and turn the outcome into an HTTP response. When a controller starts holding the rules about what a valid transfer between departments is, those rules can only be tested through HTTP and cannot be reused by anything else.
The pieces, and which ones an API actually needs:
- ControllerBase
- The base class for API controllers. It provides the result helper methods, plus access to the request, the user and model state. This is the one to inherit from for an API.
- Controller
- ControllerBase plus view support, for server-rendered pages. An API does not need it, and inheriting from it brings machinery you will not use.
- Action
- A public method on a controller that routing can select.
- ActionResult of T
- A return type that allows either the value or any other result, so one method can return an employee or a 404 without giving up its declared type.
- The ApiController attribute
- An attribute on the class that turns on a set of API conventions. What it changes is listed further down, and some of it is surprising the first time.
[ApiController]
[Route("api/employees")]
public sealed class EmployeesController(EmployeeService employees) : ControllerBase
{
[HttpGet("{id:int}")]
public async Task<ActionResult<EmployeeResponse>> GetById(int id, CancellationToken ct)
{
var employee = await employees.FindAsync(id, ct);
return employee is null
? NotFound()
: Ok(EmployeeResponse.From(employee));
}
[HttpPost]
public async Task<ActionResult<EmployeeResponse>> Create(
CreateEmployeeRequest request,
CancellationToken ct)
{
var result = await employees.CreateAsync(request, ct);
if (result.UnknownDepartmentCode)
{
return BadRequest(new { error = "That department code does not exist." });
}
return CreatedAtAction(
nameof(GetById),
new { id = result.Employee.Id },
EmployeeResponse.From(result.Employee));
}
[HttpDelete("{id:int}")]
public async Task<IActionResult> Delete(int id, CancellationToken ct)
{
var removed = await employees.DeleteAsync(id, ct);
return removed ? NoContent() : NotFound();
}
}- ActionResult of EmployeeResponse lets one method return either the object or a different result. Declaring the response type directly would make NotFound fail to compile.
- Ok wraps the value in a 200. Returning the object on its own does the same thing, and the explicit form keeps both branches symmetrical and easier to read.
- NotFound produces a 404 with no body. A caller asking for an employee who does not exist has not sent a bad request, which is why this is not a 400.
- CreatedAtAction produces a 201 and a Location header built from the GetById route, so the caller learns the address of what it created. Using nameof keeps the reference tied to the method, and the route lookup still happens at run time: if no route matches the values you supply, this throws instead of returning 201.
- NoContent is a 204 for a delete that worked. There is nothing meaningful to send, and 204 says so precisely.
- Delete returns IActionResult rather than ActionResult of T because neither branch carries a value. Both types work; the narrower one states the intent.
- CancellationToken is supplied by the framework from the request. If the caller disconnects, it is cancelled, and work passed down the stack can stop rather than finishing for nobody.
The helpers you will use most, and the status code each one produces:
- Ok(value)
- 200 with a body. Ok() with no argument is 200 with nothing in it.
- CreatedAtAction, CreatedAtRoute
- 201 with a Location header pointing at the new resource, built from a route you name.
- NoContent()
- 204. The work is done and there is deliberately nothing to send.
- BadRequest(value), ValidationProblem()
- 400. The second builds a validation problem details body from model state, which is the shape callers of an API already expect.
- Unauthorized()
- 401. No usable credential was presented.
- Forbid()
- 403 in practice. It asks the authentication scheme to handle the refusal, which is why it is a verb rather than a noun like the others.
- NotFound()
- 404. Nothing exists at this address.
- Conflict(value)
- 409. The request clashes with the current state of the data.
- Problem(...)
- A problem details body with whatever status you give it. Useful for outcomes with no dedicated helper.
What the ApiController attribute turns on, none of which is obvious from its name:
- A failed validation becomes an automatic 400 with a validation problem details body, before your method is called.
- Binding sources are inferred, so a complex parameter type is assumed to come from the request body and FromBody is rarely needed.
- Error status codes you return get a problem details body, so failures across the API have one consistent shape.
- Attribute routing becomes required for this controller. Conventional route patterns no longer apply to it.
- Parameters of type IFormFile are inferred as coming from form data rather than the body.
Summary
- ControllerBase is the base class for APIs; Controller adds view support an API does not need
- ActionResult of T lets one action return its value or a different status code
- Match the result to the outcome: Ok, CreatedAtAction with a Location header, NoContent, NotFound, BadRequest, Conflict
- The ApiController attribute turns on automatic 400s, body inference, problem details and required attribute routing
- Keep actions to reading input, delegating the work, and mapping the outcome to a status code
Practice
Attempt each one before opening the solution. Getting it wrong first is how the idea sticks.
Try it yourself
Write the update endpoint
Add PUT /api/employees/{id:int} to the controller above.
Return 204 when the update succeeds, 404 when no employee has that id, and 409 when the new email address already belongs to somebody else.
Show solution
The shape to aim for is a single call into the service that returns enough information to choose between the three outcomes. An enum or a small result type does this without the controller knowing any rules.
204 rather than 200 is a choice, and either is defensible. 204 says the update is complete and there is nothing to read, which suits a caller that already holds the values it sent. Return 200 with the updated employee instead when the server changes things the caller cannot predict, such as a computed field or a modified timestamp. What matters is being consistent across the API.
409 rather than 400 for the duplicate email is the same distinction as in the HTTP lesson: the request is well formed and the current state of the data is what stops it. A caller can retry a 409 with a different email and knows not to bother retrying the same one.
[HttpPut("{id:int}")]
public async Task<IActionResult> Update(
int id,
UpdateEmployeeRequest request,
CancellationToken ct)
{
var outcome = await employees.UpdateAsync(id, request, ct);
return outcome switch
{
UpdateOutcome.Updated => NoContent(),
UpdateOutcome.NotFound => NotFound(),
UpdateOutcome.EmailInUse => Conflict(new { error = "That email address is already in use." }),
_ => throw new InvalidOperationException($"Unhandled outcome: {outcome}"),
};
}Think about it
What a plain return type cannot say
Suppose GetById is declared as Task of EmployeeResponse rather than Task of ActionResult of EmployeeResponse.
What can the method no longer express, and what does a missing employee produce?
Show solution
It can only express success. NotFound and BadRequest are results, not employees, so neither will compile as a return value.
A missing employee means returning null, and the framework turns a null value into a success response with an empty body — by default a 204. The caller is told the request worked and there is nothing to read, which it cannot distinguish from an employee record containing nothing.
This is why ActionResult of T exists. It keeps the declared response type, which documentation generators and tests both use, while leaving room for the other outcomes an endpoint genuinely has.
Saved in this browser only.