ControllerBase (the base class for Web API controllers) provides helper methods for every common HTTP response type. Using these helpers rather than manually constructing ObjectResult with status codes keeps action methods readable and correct. The ActionResult<T> generic return type combines type safety for the happy-path response with flexibility for error responses — Swagger documents the response type automatically, and you return either the DTO or an error result.
Complete CRUD Controller
[ApiController]
[Route("api/posts")]
public class PostsController(IPostService service) : ControllerBase
{
// GET api/posts?page=1&size=10
[HttpGet]
[ProducesResponseType(typeof(PagedResult<PostSummaryDto>), 200)]
public async Task<ActionResult<PagedResult<PostSummaryDto>>> GetAll(
[FromQuery] int page = 1,
[FromQuery] int size = 10,
CancellationToken ct = default)
{
var result = await service.GetPageAsync(page, size, ct);
return Ok(result); // 200 with PagedResult body
}
// GET api/posts/42
[HttpGet("{id:int}", Name = "GetPostById")]
[ProducesResponseType(typeof(PostDto), 200)]
[ProducesResponseType(404)]
public async Task<ActionResult<PostDto>> GetById(int id, CancellationToken ct)
{
var post = await service.GetByIdAsync(id, ct);
return post is null ? NotFound() : Ok(post); // 200 or 404
}
// POST api/posts
[HttpPost]
[ProducesResponseType(typeof(PostDto), 201)]
[ProducesResponseType(typeof(ValidationProblemDetails), 400)]
public async Task<ActionResult<PostDto>> Create(
CreatePostRequest request,
CancellationToken ct)
{
var post = await service.CreateAsync(request, ct);
// 201 Created + Location header pointing to the new resource
return CreatedAtAction(nameof(GetById), new { id = post.Id }, post);
}
// PUT api/posts/42
[HttpPut("{id:int}")]
[ProducesResponseType(typeof(PostDto), 200)]
[ProducesResponseType(404)]
public async Task<ActionResult<PostDto>> Update(
int id, UpdatePostRequest request, CancellationToken ct)
{
var post = await service.UpdateAsync(id, request, ct);
return post is null ? NotFound() : Ok(post);
}
// DELETE api/posts/42
[HttpDelete("{id:int}")]
[ProducesResponseType(204)]
[ProducesResponseType(404)]
public async Task<IActionResult> Delete(int id, CancellationToken ct)
{
var existed = await service.DeleteAsync(id, ct);
return existed ? NoContent() : NotFound(); // 204 or 404
}
}
CreatedAtAction(nameof(GetById), new { id = post.Id }, post) generates a 201 response with a Location header containing the URL of the newly created resource. The first parameter is the action name (nameof(GetById) for compile-time safety), the second provides the route values ({ id = post.Id } for the {id:int} route template), and the third is the response body. This is the idiomatic way to implement POST in ASP.NET Core Web APIs.ActionResult<T> instead of IActionResult for actions where the happy-path returns a specific type. This provides two benefits: Swagger automatically documents the 200 response type without needing [ProducesResponseType(typeof(T), 200)], and the implicit operator allows returning the type directly (return post instead of return Ok(post)) for the 200 case. Use IActionResult only for actions that never return a body (like DELETE returning 204 No Content).[ProducesResponseType] attribute is documentation-only — it does not enforce what the action actually returns. If you declare [ProducesResponseType(200)] but the action returns 404, Swagger documents 200 but the actual response is 404. Use these attributes faithfully to document all possible response codes. Inaccurate API documentation is often worse than no documentation — client developers write code against the documented contract and get surprised at runtime.ControllerBase Helper Methods Reference
| Method | Status | Use When |
|---|---|---|
| Ok(data) | 200 | Successful GET, PATCH returning data |
| Created(uri, data) | 201 | POST — you have the URL manually |
| CreatedAtAction(action, route, data) | 201 | POST — generate URL from action name |
| NoContent() | 204 | DELETE, PUT with no body |
| BadRequest(errors) | 400 | Client validation failure |
| Unauthorized() | 401 | Not authenticated |
| Forbid() | 403 | Authenticated but not authorised |
| NotFound() | 404 | Resource not found |
| Conflict(data) | 409 | Duplicate resource / state conflict |
| Problem(detail) | 500 | Unexpected server error |
| StatusCode(code, data) | Any | Custom status code |
Common Mistakes
Mistake 1 — Using Ok() for creation (should be Created/CreatedAtAction)
❌ Wrong — 200 OK for a POST that created a resource; no Location header.
✅ Correct — always return 201 with a Location header for successful POST operations.
Mistake 2 — Returning 200 with a null body for successful DELETE (should be 204)
❌ Wrong — return Ok() for DELETE; returns 200 with an empty body.
✅ Correct — return NoContent() for DELETE; returns 204 with no body, correctly signalling success with no response data.