Back to: ASP.NET Core Web API Tutorials
ApiController Attribute in ASP.NET Core Web API
In this article, I will explain the ApiController Attribute in an ASP.NET Core Web API Application with examples. Please read our previous article discussing Dependency Injection in ASP.NET Core Web API Applications.
In ASP.NET Core Web API, the [ApiController] attribute plays a key role in simplifying API development and automatically enforcing RESTful best practices. When you apply [ApiController] to a controller class, it activates a set of default behaviours that make your API cleaner, safer, and more consistent.
What is [ApiController] Attribute?
The ApiController Attribute in ASP.NET Core Web API is a specialized attribute that marks a controller as an API controller. It enhances the behavior of the controller by providing some default functionalities commonly needed in Web API applications. When we apply this attribute to a controller class, we get a lot of benefits. They are as follows:
Enforcing Automatic Model Validation on invalid input
- If model binding/validation fails, including type conversion failures (ModelState.IsValid == false), ASP.NET Core automatically returns HTTP 400 with a standardized ProblemDetails payload; no need for if (!ModelState.IsValid) return BadRequest(ModelState) in every action.
Enabling Automatic Parameter Binding
- ASP.NET Core infers where to bind each parameter from:
-
- Complex types → Request Body (JSON)
- Simple/primitive types → Route or Query String
- Special cases: [FromHeader], [FromForm], etc.
- So, you rarely need [FromBody], [FromQuery], etc.
-
Required vs Optional Inputs
- With nullable reference types enabled, non-nullable parameters/properties are treated as required. Missing values produce a 400 with details. (You can turn this off.)
Consistent Client Error Payloads
- Many 4xx results (e.g., NotFound(), invalid model state) are mapped to ProblemDetails automatically, so clients get a predictable error schema.
Content Negotiation Improvements
- Incompatible body media types → 415 Unsupported Media Type
- Unfulfillable Accept header → 406 Not Acceptable (configurable)
Attribute Routing Expectation
- API controllers are expected to use Attribute Routing ([Route], [HttpGet], etc.). Conventional Routing still works, but attribute routing is the standard for APIs.
In short, it Reduces Duplicate Code and helps enforce REST API best practices automatically. Think of it as: Make this controller behave like a modern REST API by default.
What is ProblemDetails in ASP.NET Core Web API?
ProblemDetails is a standardized way of returning error responses in ASP.NET Core Web APIs. It is a built-in class that represents errors using a standard JSON structure based on the RFC 7807 specification called “Problem Details for HTTP APIs.”
In simple terms: Instead of returning random error messages like “Something went wrong” or “Invalid input”, the API can return a well-structured JSON object that explains what the problem is, why it happened, and where it occurred.
This helps both developers and clients (such as those using front-end applications or mobile apps) handle errors in a consistent format.
Structure of ProblemDetails
The following are the standard properties of the ProblemDetails class.
{ "type": "https://tools.ietf.org/html/rfc7231#section-6.5.1", "title": "One or more validation errors occurred.", "status": 400, "detail": "The Name field is required.", "instance": "/api/products/create", "errors": { "Name": ["The Name field is required."], "Price": ["The field Price must be between 1 and 100000."] } }
This JSON is automatically generated by ASP.NET Core when:
- Model validation fails (if [ApiController] is used and automatic 400 responses are enabled).
- You use helper methods like return Problem(…), NotFound(problemDetails), or BadRequest(problemDetails).
Let’s understand what each property means in simple language:
Type
- A URL that identifies the general category of the problem.
- It is not required to point to a real web page, but ideally, it should, so clients can learn more about the error type.
- For example: “https://tools.ietf.org/html/rfc7231#section-6.5.1” (for 400 Bad Request).
Use: Helps standardize the kind of problem — for instance, “validation error,” “not found,” or “forbidden.”
Title
- A short, human-readable summary of the problem type.
- Example: “One or more validation errors occurred.”
- It is meant to be consistent across occurrences of the same type of problem.
Use: Provides a simple title for the error, which is easy to display on the UI or in logs.
Status
- The HTTP status code (e.g., 400, 404, 500) associated with the error.
- It helps identify whether the error is client-side or server-side.
Use: Indicates whether the problem is a client mistake or a server issue.
Detail
- A human-readable explanation specific to this particular instance of the problem.
- Example: “The Name field is required.”
Use: Provides a more descriptive reason for why this specific request failed.
Instance
- A URI reference that identifies the specific occurrence of the problem.
- In ASP.NET Core, it’s usually set to the current request path, e.g., “/api/products/create”.
Use: Helps trace or debug the specific request that caused the problem.
Extensions
- A dictionary that lets you add custom fields to the error response.
- For example, you could include:
-
- problem.Extensions[“traceId”] = HttpContext.TraceIdentifier;
- problem.Extensions[“timestamp”] = DateTime.UtcNow;
-
- Clients can use this to correlate errors or track issues.
Use: Add extra custom data beyond the standard fields (for logging, debugging, or analytics).
Where is the Error Property?
When model validation fails, ASP.NET Core does not return a plain ProblemDetails. It returns a derived class called ValidationProblemDetails, which extends the base ProblemDetails and adds an extra property: Errors. So, the actual type of your response object is ValidationProblemDetails, not the plain ProblemDetails.
Example to Understand the Use of [ApiController] Attribute in ASP.NET Core Web API:
First, create a new ASP.NET Core Web API Project and name it ApiControllerDemo. Then, create a folder named DTOs in the project root directory. Next, create a class file named ProductDTO.cs within the DTOs folder, and copy-paste the following code.
using System.ComponentModel.DataAnnotations; namespace ApiControllerDemo.DTOs { public class ProductDTO { [Required(ErrorMessage = "Product Name is required")] [StringLength(100, MinimumLength = 3, ErrorMessage = "Product Name must be between 3 and 100 characters.")] public string Name { get; set; } [Range(1, 100000, ErrorMessage = "Price must be between 1 and 100000")] public decimal Price { get; set; } [Required(ErrorMessage = "Category is required")] public string Category { get; set; } public bool IsActive { get; set; } = true; } }
Creating Controller
Next, create an API Empty Controller named ProductsController within the Controllers folder, and then copy-paste the following code. Using this controller, we will demonstrate all the use cases of the ApiController attribute.
using ApiControllerDemo.DTOs; using Microsoft.AspNetCore.Mvc; namespace ApiControllerDemo.Controllers { [ApiController] // Enables API-friendly defaults [Route("api/[controller]")] // Attribute routing public class ProductsController : ControllerBase { private static readonly List<ProductDTO> Products = new() { new ProductDTO { Name = "Laptop", Price = 50000, Category = "Electronics" }, new ProductDTO { Name = "Book", Price = 499, Category = "Stationery" } }; } }
Use Case 1: Automatic Model Validation
When we apply [ApiController], ASP.NET Core automatically validates incoming model data against the Data Annotations defined in our DTO. If validation fails, it returns a 400 Bad Request response automatically, before the controller action executes.
Without [ApiController]
// POST /api/products/create [HttpPost("create")] public IActionResult CreateProduct([FromBody] ProductDTO productDTO) { // Manual validation required. if (!ModelState.IsValid) return BadRequest(ModelState); Products.Add(productDTO); return Ok(new { Message = "Product added successfully", Data = productDTO }); }
With [ApiController]
// POST /api/products/create [HttpPost("create")] public IActionResult CreateProduct(ProductDTO productDTO) { // Automatic model validation happens here. Products.Add(productDTO); return Ok(new { Message = "Product added successfully", Data = productDTO }); }
Explanation:
In the first version (without [ApiController]), you must manually check ModelState.IsValid after model binding to ensure that the input data follows the rules specified in your ProductDTO. For instance, if the client omits the Category or sets Price = 0, the model will be invalid, and you’ll have to return a BadRequest explicitly.
However, with [ApiController] applied, ASP.NET Core automatically performs model validation as soon as the request reaches the controller. If the input data fails validation, the framework immediately stops execution and sends a 400 Bad Request response with a standardized ValidationProblemDetails JSON payload. The controller action will not execute at all in such cases.
This automation not only eliminates repetitive code but also ensures every API endpoint provides consistent validation behavior across the application.
Use Case 2: Automatic Parameter Binding
Without [ApiController], we often have to use [FromBody], [FromRoute], or [FromQuery] attributes explicitly. With [ApiController], ASP.NET Core automatically infers the binding source based on parameter type.
Without [ApiController]
// POST /api/products/create/10 [HttpPost("create/{discount}")] public IActionResult CreateWithDiscount([FromRoute] decimal discount, [FromBody] ProductDTO productDTO) { if (!ModelState.IsValid) return BadRequest(ModelState); var discountedPrice = productDTO.Price - (productDTO.Price * discount / 100); return Ok(new { productDTO.Name, OriginalPrice = productDTO.Price, DiscountedPrice = discountedPrice }); }
With [ApiController]
// POST /api/products/create/10 [HttpPost("create/{discount}")] public IActionResult CreateWithDiscount(decimal discount, ProductDTO productDTO) { // discount → bound automatically from route // productDTO → bound automatically from request body var discountedPrice = productDTO.Price - (productDTO.Price * discount / 100); return Ok(new { productDTO.Name, OriginalPrice = productDTO.Price, DiscountedPrice = discountedPrice }); }
Explanation
In the first version, without [ApiController], you need to tell the framework where each parameter comes from explicitly. For example, the discount parameter must come from the route, so you use [FromRoute], while the ProductDTO object should be read from the body, so you add [FromBody].
Once you enable [ApiController], ASP.NET Core automatically infers parameter binding sources:
- Simple types (like int, bool, decimal, or string) are inferred from route or query parameters.
- Complex types (like DTOs or models) are inferred from the request body.
This feature drastically reduces clutter and makes controller actions cleaner and easier to read.
Use Case 3: Automatic HTTP 400 Responses for Invalid Input
If model binding or validation fails, [ApiController] ensures a 400 Bad Request is automatically returned, even before your controller logic runs.
Without [ApiController]
// POST /api/products/validate [HttpPost("validate")] public IActionResult ValidateProduct([FromBody] ProductDTO productDTO) { if (!ModelState.IsValid) return BadRequest(ModelState); return Ok("Model validation succeeded!"); }
With [ApiController]
// POST /api/products/validate [HttpPost("validate")] public IActionResult ValidateProduct(ProductDTO productDTO) { // Action executes only if model is valid return Ok("Model validation succeeded!"); }
Explanation:
When the [ApiController] attribute is not present, invalid input must be manually handled; otherwise, bad data might enter the system. You are responsible for calling if (!ModelState.IsValid) and returning a proper response.
However, with [ApiController], invalid model states trigger an automatic 400 Bad Request response, even before the controller method executes. This behavior ensures that your API always responds in a consistent and predictable way whenever client-side data fails validation.
This is especially useful for large APIs because you don’t need to repeat validation checks in every controller action.
Use Case 4: Manually Returning ProblemDetails for Not Found / Errors
While [ApiController] automatically handles validation errors, you can also manually create standardized error responses for scenarios like Not Found, Conflict, or Internal Server Error. The ProblemDetails class provides a consistent format for error payloads.
With or Without [ApiController]
// GET /api/products/{name} [HttpGet("{name}")] public IActionResult GetProductByName(string name) { var product = Products.FirstOrDefault(p => p.Name.Equals(name, StringComparison.OrdinalIgnoreCase)); if (product == null) { // Create a detailed ProblemDetails object var problem = new ProblemDetails { Type = "https://httpstatuses.io/404", // Optional: Link explaining the error type Title = "Product Not Found", // Short, human-readable title Status = StatusCodes.Status404NotFound,// HTTP Status Code Detail = $"The product '{name}' does not exist in the catalog.", Instance = HttpContext.Request.Path // The current request path }; // Add custom data using Extensions dictionary problem.Extensions["traceId"] = HttpContext.TraceIdentifier; problem.Extensions["timestamp"] = DateTime.UtcNow; problem.Extensions["supportContact"] = "support@dotnettutorials.net"; problem.Extensions["suggestion"] = "Verify the product name or check available product list."; problem.Extensions["correlationId"] = Guid.NewGuid().ToString(); return NotFound(problem); } return Ok(product); }
Explanation:
In this example, the [ApiController] attribute ensures that your Web API automatically returns consistent error responses using the ProblemDetails format. When a product is not found, you manually create a ProblemDetails object to send a structured 404 response with extra details like trace ID, timestamp, and support contact. Even for other automatic 400-level errors (like missing parameters or validation failures), [ApiController] ensures they all use the same standardized ProblemDetails structure, keeping your API responses clean and predictable.
Without [ApiController] – Using ModelState
// POST /api/products/manual-validate [HttpPost("manual-validate")] public IActionResult ManualValidateProduct([FromBody]ProductDTO productDTO) { if (!ModelState.IsValid) { var validationProblem = new ValidationProblemDetails(ModelState) { Status = StatusCodes.Status400BadRequest, Title = "Validation Failed", Detail = "Some fields contain invalid values.", Instance = HttpContext.Request.Path }; // You can also add custom extension fields validationProblem.Extensions["traceId"] = HttpContext.TraceIdentifier; validationProblem.Extensions["timestamp"] = DateTime.UtcNow; return BadRequest(validationProblem); } return Ok(new { Message = "Product created successfully", Data = productDTO }); }
Explanations:
Here, the [ApiController] attribute is removed, so the automatic model validation no longer happens. You must check ModelState.IsValid manually and then create a ValidationProblemDetails object if validation fails. This object includes all field-specific validation messages from the ModelState, along with custom details such as a title, status, and timestamp. This approach still follows the same structured error format, but it requires you to handle validation and response creation yourself.
Without [ApiController] Manually Adding Errors
[HttpPost("manual-validate")] public IActionResult ManualValidateProduct([FromBody] ProductDTO productDTO) { // Suppose you want to perform custom validation logic manually var errors = new Dictionary<string, string[]>(); if (string.IsNullOrWhiteSpace(productDTO.Name)) errors.Add(nameof(productDTO.Name), new[] { "Product name cannot be empty." }); if (productDTO.Price <= 0) errors.Add(nameof(productDTO.Price), new[] { "Price must be greater than zero." }); if (string.IsNullOrWhiteSpace(productDTO.Category)) errors.Add(nameof(productDTO.Category), new[] { "Category cannot be empty." }); // If there are any custom errors, return ValidationProblemDetails manually if (errors.Any()) { var validationProblem = new ValidationProblemDetails(errors) { Status = StatusCodes.Status400BadRequest, Title = "Validation Failed", Detail = "One or more fields contain invalid data.", Instance = HttpContext.Request.Path }; // Optionally add custom extension info validationProblem.Extensions["traceId"] = HttpContext.TraceIdentifier; validationProblem.Extensions["timestamp"] = DateTime.UtcNow; return BadRequest(validationProblem); } // If valid, process normally return Ok(new { Message = "Product created successfully!", Data = productDTO }); }
Explanations:
In this example, you perform your own custom validation logic without relying on model attributes or [ApiController]. You manually collect validation errors in a dictionary and return them using ValidationProblemDetails if any rule fails. This gives you full control over validation rules (like checking price limits or custom business conditions) while still keeping the error response consistent and standardized. It’s ideal when you want both flexibility and a properly structured error output.
Use Case 5: Attribute Routing Requirement
The [ApiController] attribute requires all API endpoints to use attribute routing, not conventional MVC-style routing. This ensures all endpoints are explicit and well-defined for clients.
Without [ApiController]
// GET /api/products/all [HttpGet("all")] public IActionResult GetAllProducts() { return Ok(Products); }
With [ApiController]
// GET /api/products/all [HttpGet("all")] public IActionResult GetAllProducts() { return Ok(Products); }
Explanation:
Without [ApiController], you can still use attribute routing, but it’s not mandatory; you could rely on conventional routing defined in Program.cs. However, when [ApiController] is applied, attribute routing becomes mandatory. Each action must explicitly specify its route using [HttpGet], [HttpPost], etc.
This design enforces clarity and avoids accidental route conflicts, making your API endpoints more explicit and easier to maintain.
Use Case 6: Binding from Query Parameters in GET
With [ApiController], simple types like strings, bools, and numbers in GET methods are automatically bound from the query string — no attributes needed.
Without [ApiController]
// GET /api/products/filter?category=Electronics&isActive=true [HttpGet("filter")] public IActionResult FilterProducts([FromQuery] string category, [FromQuery] bool isActive = true) { var filtered = Products .Where(p => p.Category.Equals(category, StringComparison.OrdinalIgnoreCase) && p.IsActive == isActive) .ToList(); return Ok(filtered); }
With [ApiController]
// GET /api/products/filter?category=Electronics&isActive=true [HttpGet("filter")] public IActionResult FilterProducts(string category, bool isActive = true) { var filtered = Products .Where(p => p.Category.Equals(category, StringComparison.OrdinalIgnoreCase) && p.IsActive == isActive) .ToList(); return Ok(filtered); }
Explanation
When [ApiController] is active, query parameters are automatically bound to simple method arguments. You don’t need to write [FromQuery] explicitly. If you remove the attribute, it’s safer to include [FromQuery] to specify where parameters are coming from clearly.
This feature is particularly helpful for endpoints that filter or search resources based on query parameters, such as /api/products/filter?category=Electronics&isActive=true.
Use Case 7: Binding from Route Parameters in GET
[ApiController] also supports automatic binding for route parameters. If your route template includes a variable, ASP.NET Core automatically maps it to the corresponding method parameter.
Without [ApiController]
// GET /api/products/details/0 [HttpGet("details/{index}")] public IActionResult GetProductByIndex([FromRoute] int index) { if (index < 0 || index >= Products.Count) return NotFound(new { Message = "Invalid product index" }); return Ok(Products[index]); }
With [ApiController]
// GET /api/products/details/0 [HttpGet("details/{index}")] public IActionResult GetProductByIndex(int index) { if (index < 0 || index >= Products.Count) return NotFound(new { Message = "Invalid product index" }); return Ok(Products[index]); }
Explanation
When [ApiController] is used, ASP.NET Core automatically maps route placeholders (like {index}) to corresponding parameters by name. Without the attribute, explicit [FromRoute] is recommended for clarity.
This automation is small but significant, especially when your APIs have multiple route parameters.
Use Case 8: Content Negotiation with [Produces] / [Consumes]
Content negotiation allows your API to communicate using the format requested by the client. The [Produces] and [Consumes] attributes let you explicitly control input and output formats.
Without [ApiController]
// POST /api/products/upload [HttpPost("upload")] [Consumes("application/json")] [Produces("application/xml")] public IActionResult UploadProduct([FromBody] ProductDTO productDTO) { if (!ModelState.IsValid) return BadRequest(ModelState); return Ok(productDTO); }
With [ApiController]
// POST /api/products/upload [HttpPost("upload")] [Consumes("application/json")] [Produces("application/xml")] public IActionResult UploadProduct(ProductDTO productDTO) { return Ok(productDTO); // Will return XML if client requests it }
Explanation:
Content negotiation determines the request and response formats between client and server.
With [ApiController], the framework automatically enforces rules defined by [Consumes] and [Produces]. For instance, if a client sends data with an unsupported content type, ASP.NET Core returns 415 (Unsupported Media Type). If it requests an unsupported response format, it returns 406 (Not Acceptable).
Without [ApiController], these checks do not occur automatically; you must handle them manually if necessary. This automation ensures that your API adheres to proper content-negotiation standards and communicates clearly with different types of clients.
Use Case 9: Automatic 415/406 for Bad Media Types
When content negotiation fails (e.g., an unsupported Content-Type or Accept header), [ApiController] ensures automatic 415 (Unsupported Media Type) or 406 (Not Acceptable) responses.
Without [ApiController]
// POST /api/products/media [HttpPost("media")] public IActionResult HandleMedia([FromBody] ProductDTO productDTO) { if (!Request.ContentType.Contains("application/json")) return StatusCode(415, "Unsupported Media Type"); if (!ModelState.IsValid) return BadRequest(ModelState); return Ok(new { Message = "Media type accepted", productDTO }); }
With [ApiController]
// POST /api/products/media [HttpPost("media")] [Consumes("application/json")] [Produces("application/json")] public IActionResult HandleMedia(ProductDTO productDTO) { return Ok(new { Message = "Media type accepted", productDTO }); }
Explanation:
Without [ApiController], ASP.NET Core does not automatically validate content negotiation. You have to check the ContentType and Accept headers manually. But when [ApiController] is enabled, the framework performs these validations automatically, returning 415 or 406 responses where appropriate.
This makes your API more robust and standards-compliant without adding any extra logic.
Use Case 10: Automatic 400 for Non-Nullable Parameters
With nullable reference types enabled, [ApiController] automatically treats non-nullable parameters as required. If the parameter is missing from the request, ASP.NET Core automatically returns a 400 response.
Without [ApiController]
// GET /api/products/required?name=Phone [HttpGet("required")] public IActionResult RequiresNonNull([FromQuery] string name) { if (string.IsNullOrWhiteSpace(name)) return BadRequest(new { Message = "The 'name' parameter is required." }); var matches = Products.Where(p => p.Name.Contains(name, StringComparison.OrdinalIgnoreCase)); return Ok(matches); }
With [ApiController]
// GET /api/products/required?name=Phone [HttpGet("required")] public IActionResult RequiresNonNull(string name) { var matches = Products.Where(p => p.Name.Contains(name, StringComparison.OrdinalIgnoreCase)); return Ok(matches); }
Explanation
When nullable reference types are enabled, [ApiController] automatically treats non-nullable parameters as required. If the client does not provide a required query parameter, ASP.NET Core automatically returns a 400 Bad Request with an error message indicating which parameter is missing.
Without [ApiController], you must manually check for null or empty parameters and handle them using BadRequest() responses.
Understanding MVC-Level and Action-Specific Behaviors in ASP.NET Core:
In ASP.NET Core, when you call builder.Services.AddControllers(), you enable two layers of functionality:
- The MVC-level behaviors, which form the foundation of model binding, validation, routing, and filters.
- The API-specific or action-level behaviors come into play when you use the [ApiController] attribute on your controller.
The MVC layer provides the general framework for handling HTTP requests. At the same time, the [ApiController] attribute adds smart API-specific features such as automatic model validation, parameter binding inference, and standardized error responses.
MVC Level Behaviours
MVC-level behaviours define the core rules of how the ASP.NET Core MVC framework works.
They are the foundation used by both web apps (MVC + Razor Views) and APIs. Examples of MVC-level behaviours include:
- How model binding and validation work globally.
- How routing maps URLs to controller actions.
- How filters (like Authorization or Exception filters) are applied.
- How non-nullable reference types are treated (whether they’re implicitly [Required] or not).
Action-Specific (API) Behaviors
Once you decorate a controller with [ApiController] attribute, ASP.NET Core enables API-specific defaults that act directly at the action level. These are special behaviours that simplify Web API development. Some examples include:
- Automatically returns 400 Bad Request if model validation fails.
- Automatically inferring binding sources for parameters ([FromBody], [FromQuery], etc.).
- Automatically wrapping 4xx responses in standardized ProblemDetails JSON format.
- Treating non-nullable parameters as required automatically.
How to Customize MVC and Action Behaviors
- To customize MVC-level behaviours, use .AddMvcOptions() inside AddControllers(). This is where we control framework-wide behaviours such as formatter settings, filters, and model validation rules.
- To customize API-specific (action-level) behaviours, use .ConfigureApiBehaviorOptions(). This allows us to modify how [ApiController] works, for example, you can disable automatic 400 responses or define your own validation error format.
Together, these two sections let you control both how the MVC engine behaves globally and how your API actions respond specifically to client requests.
Customizing ApiBehaviorOptions and MvcOptions in ASP.NET Core Web API:
By default, when we apply the [ApiController] attribute in ASP.NET Core Web API, the framework automatically handles model validation, parameter binding, and error responses using standard conventions such as returning a 400 Bad Request with ValidationProblemDetails.
While this is convenient, many real-world APIs need more control, for example, to:
- Return custom validation messages
- Disable automatic 400s and handle validation manually
- Avoid implicit [Required] attributes for non-nullable reference types
- Return simpler, frontend-friendly error payloads
The following configuration shows how to override these default behaviours using AddMvcOptions() and ConfigureApiBehaviorOptions().
Modifying Program Class:
Please modify the Program class as follows.
using Microsoft.AspNetCore.Mvc; namespace ApiControllerDemo { public class Program { public static void Main(string[] args) { var builder = WebApplication.CreateBuilder(args); // Add services to the container. builder.Services .AddControllers() // Configure MVC-level behaviors .AddMvcOptions(mvcOptions => { // ------------------------------------------------------------- // Disable implicit [Required] for non-nullable reference types // ------------------------------------------------------------- // By default, ASP.NET Core treats non-nullable parameters (like string) // as implicitly required, even if you don’t add [Required]. // Setting this to true gives you full control over when [Required] should be applied. // This is helpful for partial updates (PATCH) or optional parameters in APIs. mvcOptions.SuppressImplicitRequiredAttributeForNonNullableReferenceTypes = true; }) // ======================================== // Configure API-specific default behaviors // ======================================== .ConfigureApiBehaviorOptions(options => { // ------------------------------------------------------- // 1) Suppress automatic 400 responses for invalid models // ------------------------------------------------------- // By default, [ApiController] automatically returns 400 Bad Request // when the incoming model fails validation (ModelState.IsValid = false). // Setting this to TRUE means you’ll handle ModelState manually in actions. options.SuppressModelStateInvalidFilter = true; // -------------------------------------------------- // 2️) Disable automatic inference of binding sources // -------------------------------------------------- // Normally, ASP.NET infers binding sources automatically: // - Complex types → [FromBody] // - Primitives → [FromQuery] or [FromRoute] // Setting this to TRUE forces you to specify binding explicitly, // giving you full control and making action methods self-documenting. options.SuppressInferBindingSourcesForParameters = true; // --------------------------------------------------------------- // 3️) Turn off automatic ProblemDetails mapping for 4xx responses // --------------------------------------------------------------- // By default, ASP.NET Core wraps client errors like 404/401/400 // inside ProblemDetails objects automatically. // Disabling this ensures your responses are returned exactly as you define. options.SuppressMapClientErrors = true; // ------------------------------------------------ // 4️) Customize model validation response globally // ------------------------------------------------ // When validation fails, ASP.NET calls this delegate to generate the error response. // You can override it to return a custom structure. options.InvalidModelStateResponseFactory = context => { var errors = context.ModelState .Where(kv => kv.Value?.Errors.Count > 0) .ToDictionary( kv => kv.Key, kv => kv.Value!.Errors.Select(e => e.ErrorMessage)); var customResponse = new { Message = "Input validation failed.", Errors = errors }; return new BadRequestObjectResult(customResponse); }; }); builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(); var app = builder.Build(); // Configure the HTTP request pipeline. if (app.Environment.IsDevelopment()) { app.UseSwagger(); app.UseSwaggerUI(); } app.UseHttpsRedirection(); app.UseAuthorization(); app.MapControllers(); app.Run(); } } }
Controller to Test Configurations
Modify ProductsController as follows.
using ApiControllerDemo.DTOs; using Microsoft.AspNetCore.Mvc; namespace ApiControllerDemo.Controllers { [ApiController] [Route("api/[controller]")] public class ProductsController : ControllerBase { private static readonly List<ProductDTO> Products = new() { new ProductDTO { Name = "Laptop", Price = 50000, Category = "Electronics" }, new ProductDTO { Name = "Book", Price = 499, Category = "Stationery" } }; // Custom Validation Response (ModelState will be auto-formatted by custom factory) [HttpPost("create")] public IActionResult CreateProduct([FromBody] ProductDTO product) { // We disabled SuppressModelStateInvalidFilter = true, // but since we also provided InvalidModelStateResponseFactory, it will still work. return Ok(new { Message = "Product created", Data = product }); } // Test explicit binding (required due to SuppressInferBindingSourcesForParameters) [HttpPost("discounted")] public IActionResult CreateDiscountedProduct([FromQuery] decimal discount, [FromBody] ProductDTO product) { var discountedPrice = product.Price - (product.Price * discount / 100); return Ok(new { product.Name, product.Price, DiscountedPrice = discountedPrice }); } // Demonstrate manual model validation (if you fully suppress automatic response) [HttpPost("validate-manual")] public IActionResult ValidateManually([FromBody] ProductDTO product) { if (!ModelState.IsValid) { return BadRequest(new { Message = "Manual validation failed.", Errors = ModelState .Where(e => e.Value!.Errors.Any()) .ToDictionary( e => e.Key, e => e.Value!.Errors.Select(err => err.ErrorMessage)) }); } return Ok("Validation passed manually!"); } // Test parameter binding enforcement // This will break if [FromQuery] is removed due to SuppressInferBindingSourcesForParameters [HttpGet("required")] public IActionResult RequireParameter([FromQuery] string name) { var matches = Products.Where(p => p.Name.Contains(name, StringComparison.OrdinalIgnoreCase)); return Ok(matches); } } }
How to Test in Swagger
- POST /api/products/create
-
- Send invalid JSON (missing Name, Price = 0)
- Should return your custom error format.
-
- POST /api/products/discounted?discount=10
-
- Test [FromQuery] + [FromBody] together.
-
- POST /api/products/validate-manual
-
- You manually check ModelState.IsValid.
-
- GET /api/products/required?name=Laptop
-
- If you skip ?name=, you get a 400 because name is required (and binding is strict).
-
Conclusion:
The [ApiController] attribute is more than just a decoration; it’s a behavioral switch that converts your controller into a modern RESTful API endpoint with minimal effort. It automates common tasks like validation, parameter binding, and media-type checking, ensuring your APIs behave consistently and predictably.
When you build APIs in ASP.NET Core, it’s strongly recommended to use [ApiController] for all controllers that serve JSON or XML responses. This approach reduces repetitive code, enforces best practices, and provides a better developer and client experience overall.
By understanding these use cases, you can now appreciate how much simpler and safer your code becomes with [ApiController], a small attribute that delivers huge benefits in the world of Web API development.
In the next article, I will discuss how to create a Background Service in ASP.NET Core Web API applications with Examples. Here, I explain the ApiController Attribute in the ASP.NET Core Web API application with multiple Examples. I hope you enjoy this article on “ApiController Attribute in ASP.NET Core Web API.”