[ApiController]

Switch on the API behaviours of MVC controllers (automatic 400s, binding inference, problem details) and tune or disable each one with ApiBehaviorOptions.

Signature
[ApiController] public class OrdersController : ControllerBase  |  [assembly: ApiController]
  • Added in .NET ASP.NET Core 2.1

Return value

n/aMarks the controller (or every controller in the assembly) as an HTTP API controller; at startup MVC applies the API conventions configured in ApiBehaviorOptions to its actions.

Errors

An action on an [ApiController] reachable only through a conventional route fails startup with InvalidOperationException. Ambiguous binding inference (for example two inferred body parameters) also fails at startup.

Changelog

  • PHP ASP.NET Core 2.2Error status results without a body (404, 409 and others) get a ProblemDetails body; SuppressMapClientErrors turns it off.
  • PHP ASP.NET Core 7.0Parameters whose type is registered in DI are inferred as [FromServices]; DisableImplicitFromServicesParameters turns it off.
  • PHP ASP.NET Core 10Cookie authentication answers 401/403 instead of redirecting to a login page for [ApiController] endpoints.

[ApiController] has no properties. It is a marker (it implements IApiBehaviorMetadata) that an application model provider looks for at startup. For every controller that carries it, directly, through a base class or through [assembly: ApiController], MVC rewrites the action model: it adds a filter, infers binding sources and registers error mappings. Nothing happens per request that you could not have written yourself; the attribute just makes the safe API defaults automatic.

What it switches on

BehaviourEffectSwitch off with ApiBehaviorOptions
Attribute routing requiredEvery action needs a route from [Route] or [HttpGet("...")]; startup fails otherwise.Cannot be switched off.
Automatic 400ModelStateInvalidFilter returns a ValidationProblemDetails body before the action runs.SuppressModelStateInvalidFilter
Binding source inferenceComplex types from the body, IFormFile from the form, names in the route template from the route, DI-registered types from services, everything else from the query string.SuppressInferBindingSourcesForParameters, DisableImplicitFromServicesParameters
multipart/form-data constraintActions with IFormFile parameters only match form requests.SuppressConsumesConstraintForFormFileParameters
Client error mappingNotFound(), Conflict() and other bodiless results of 400 and above get an RFC 9457 body with type, title and traceId.SuppressMapClientErrors

The decision a team makes

Put it on every API controller, ideally once: on the assembly or on a shared base class (the attribute is inherited). The real decisions are about the automatic 400. Keep it, and shape the body through InvalidModelStateResponseFactory if your API contract needs extra fields such as an error code. Suppressing the filter means every action must check ModelState.IsValid itself, and the one that forgets saves invalid data.

What goes wrong in production

  • Invisible 400s. The filter short-circuits before your action, so nothing in your code logs the rejection. Log inside a custom InvalidModelStateResponseFactory, or rely on request logging, when clients report unexplained 400s.
  • Inference surprises. A complex type on a GET action is inferred as [FromBody], so the request gets 415 or 400 instead of reading the query string. Add [FromQuery] to the parameter.
  • Two error formats. Minimal API endpoints and [ApiController] actions produce problem details through different code paths. Call AddProblemDetails() so both, and the exception handler, share one shape.

Example

Try it yourself
Loading editor…
Press Run to execute the code.

When to use it

  • Return a consistent RFC 9457 validation body for every invalid POST /api/orders without a single ModelState check in the actions
  • Add a stable VALIDATION_FAILED error code to every automatic 400 so mobile clients can map it to a form message
  • Make a legacy controller assembly answer 404 with a problem details body that carries a traceId support can search for

Frequently asked questions

Do I still need [ApiController] if I only use minimal APIs?

No. It only affects MVC controllers. Minimal APIs infer binding sources themselves and validate with AddValidation().

Why does my GET action with a filter object return 415?

With [ApiController] a complex type parameter is inferred as [FromBody], and a GET request has no JSON body. Put [FromQuery] on the parameter so its properties bind from the query string.

Is the attribute inherited?

Yes. Placing it on an abstract base controller applies it to every derived controller, and [assembly: ApiController] applies it to the whole assembly.

More examples

Apply it once for the whole API

<code>[assembly: ApiController]</code> applies the conventions to every controller in the assembly, which also covers controllers added later by someone who forgets the attribute. A base class is the alternative when one assembly mixes API controllers with MVC views; pick one approach, not both.

Example · csharp
using Microsoft.AspNetCore.Mvc;

// Every controller in this assembly is an API controller.
[assembly: ApiController]

namespace Shop.Api;

// Alternatively, a shared base class: the attribute is inherited.
[ApiController]
[Produces("application/json")]
public abstract class ApiControllerBase : ControllerBase;

[Route("api/invoices")]
public class InvoicesController : ApiControllerBase
{
    [HttpGet("{id:int}")]
    public IActionResult Get(int id) => id > 0 ? Ok(new { id, total = 129.90m }) : NotFound();
}

Reshape and log validation failures

The factory replaces the body the automatic 400 produces while keeping the short-circuit. Logging only field names, never values, keeps personal data out of the logs. Clients that switch on a stable <code>code</code> field are spared from parsing the human-readable title.

Example · csharp
using Microsoft.AspNetCore.Mvc;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.AddProblemDetails();

builder.Services.Configure<ApiBehaviorOptions>(options =>
{
    options.InvalidModelStateResponseFactory = context =>
    {
        var logger = context.HttpContext.RequestServices
            .GetRequiredService<ILoggerFactory>()
            .CreateLogger("Validation");
        logger.LogInformation("Rejected {Path}: {Fields}",
            context.HttpContext.Request.Path,
            string.Join(",", context.ModelState.Where(e => e.Value!.Errors.Count > 0).Select(e => e.Key)));

        var problem = new ValidationProblemDetails(context.ModelState)
        {
            Status = StatusCodes.Status400BadRequest,
            Type = "https://errors.example.com/validation",
            Title = "The request is invalid.",
        };
        problem.Extensions["code"] = "VALIDATION_FAILED";
        problem.Extensions["traceId"] = context.HttpContext.TraceIdentifier;
        return new BadRequestObjectResult(problem) { ContentTypes = { "application/problem+json" } };
    };
});

var app = builder.Build();
app.MapControllers();
app.Run();

Turning one behaviour off, and the price

<code>SuppressModelStateInvalidFilter</code> is global, so the manual <code>ModelState.IsValid</code> check must appear in every action. One forgotten check lets a refund with a negative amount through. Prefer keeping the filter and customising <code>InvalidModelStateResponseFactory</code>.

Example · csharp
using System.ComponentModel.DataAnnotations;
using Microsoft.AspNetCore.Mvc;

namespace Shop.Api;

public record RefundRequest([Range(0.01, 10000)] decimal Amount, [Required] string Reason);

// Program.cs:
// builder.Services.Configure<ApiBehaviorOptions>(o => o.SuppressModelStateInvalidFilter = true);

[ApiController]
[Route("api/payments")]
public class PaymentsController : ControllerBase
{
    [HttpPost("{paymentId}/refunds")]
    public IActionResult Refund(string paymentId, RefundRequest request)
    {
        // With the filter suppressed this check is mandatory in EVERY action.
        if (!ModelState.IsValid)
        {
            return ValidationProblem(ModelState);
        }

        return Accepted(new { paymentId, request.Amount });
    }
}

Discussion

  • Be the first to comment on this lesson.