ControllerBase — HTTP Responses and Helper Methods

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
    }
}
Note: 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.
Tip: Use 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).
Warning: The [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.

🧠 Test Yourself

A DELETE action uses return Ok() vs return NoContent(). What is the practical difference for API clients?