The [ApiController] Attribute — Automatic Behaviour

The [ApiController] attribute on a controller class enables a set of opinionated behaviours that make Web API controllers cleaner and more consistent. Without it, every action method needs to check ModelState.IsValid, every parameter needs explicit binding source attributes, and error responses need manual formatting. With it, these concerns are handled automatically by the framework, letting you focus on business logic.

What [ApiController] Enables

// ── Without [ApiController] — verbose, manual ─────────────────────────────
public class OldPostsController : ControllerBase
{
    [HttpPost]
    public async Task<IActionResult> Create([FromBody] CreatePostRequest request)
    {
        if (!ModelState.IsValid)
            return BadRequest(ModelState);   // must check manually!

        var post = await _service.CreateAsync(request);
        return CreatedAtAction(nameof(GetById), new { post.Id }, post);
    }
}

// ── With [ApiController] — clean, automatic ───────────────────────────────
[ApiController]
[Route("api/posts")]
public class PostsController : ControllerBase
{
    [HttpPost]
    public async Task<ActionResult<PostDto>> Create(CreatePostRequest request)
    // No [FromBody] needed — [ApiController] infers it for complex types
    // No ModelState.IsValid check — 400 returned automatically if invalid
    {
        var post = await _service.CreateAsync(request);
        return CreatedAtAction(nameof(GetById), new { post.Id }, post.ToDto());
    }

    // ── Behaviour 1: Automatic 400 on invalid ModelState ─────────────────
    // If CreatePostRequest has [Required] Title and Title is missing:
    // → Framework returns 400 with ProblemDetails body BEFORE entering Create()
    // → No need for if (!ModelState.IsValid) check in the action

    // ── Behaviour 2: Binding source inference ─────────────────────────────
    // Complex types → [FromBody] (JSON body)
    // Simple types matching route template → [FromRoute]
    // Other simple types → [FromQuery]
    // IFormFile → [FromForm]
    [HttpGet("{id:int}")]
    public async Task<ActionResult<PostDto>> GetById(int id)  // id from route, no [FromRoute] needed
    {
        var post = await _service.GetByIdAsync(id);
        return post is null ? NotFound() : Ok(post.ToDto());
    }
}
Note: When [ApiController] automatically returns a 400 response for invalid ModelState, it returns a ValidationProblemDetails object — a ProblemDetails (RFC 7807) response with an errors property containing field-specific messages. The response looks like: { "type": "https://tools.ietf.org/html/rfc7807", "title": "One or more validation errors occurred.", "status": 400, "errors": { "Title": ["The Title field is required."] } }. This is the standard format that Angular’s error handling code should expect from the API.
Tip: Use ActionResult<T> as the return type instead of IActionResult for Web API actions. ActionResult<PostDto> lets you return either an ActionResult (NotFound, BadRequest, etc.) or a PostDto directly, and Swagger can automatically document the response type. With IActionResult, Swagger does not know what type the 200 response contains. The difference: return post.ToDto() (implicit 200) works with ActionResult<PostDto> but requires return Ok(post.ToDto()) with IActionResult.
Warning: The automatic ModelState validation in [ApiController] runs as an action filter, not in the action body. This means if you add a [ServiceFilter] with a higher priority than the ModelState validation filter, it may run before validation. The default ModelState validation filter has Order = -2000 (runs very early). Custom filters with default order (0) run after validation. If you need to run something before validation (like request logging), set your filter’s Order to a value lower than -2000.

Customising the Automatic 400 Response

// ── Customise the automatic validation failure response ───────────────────
builder.Services.Configure<ApiBehaviorOptions>(options =>
{
    // Customise the ValidationProblemDetails factory
    options.InvalidModelStateResponseFactory = context =>
    {
        var problemDetails = new ValidationProblemDetails(context.ModelState)
        {
            Type   = "https://blogapp.com/errors/validation",
            Title  = "Validation failed.",
            Status = StatusCodes.Status422UnprocessableEntity,
            Detail = "See the errors property for details.",
            Instance = context.HttpContext.Request.Path,
        };
        problemDetails.Extensions["traceId"] =
            context.HttpContext.TraceIdentifier;

        return new UnprocessableEntityObjectResult(problemDetails)
        {
            ContentTypes = { "application/problem+json" }
        };
    };
});

Common Mistakes

Mistake 1 — Manually checking ModelState.IsValid when [ApiController] is applied

❌ Wrong — redundant; [ApiController] already handles this automatically:

[ApiController]
public async Task<IActionResult> Create(CreatePostRequest r)
{
    if (!ModelState.IsValid) return BadRequest(ModelState);  // redundant!

✅ Correct — omit the ModelState check; the framework returns 400 before the action body runs.

Mistake 2 — Adding [FromBody] explicitly to complex type parameters (redundant with [ApiController])

❌ Redundant — [ApiController] infers [FromBody] for complex types automatically.

✅ Correct — omit [FromBody] for request body parameters; use explicit [FromQuery] or [FromRoute] only when overriding the default inference.

🧠 Test Yourself

A POST action with [ApiController] receives a request with an invalid body (missing required Title). The action method has no ModelState check. What is returned?