Overview
[ApiController] is an ASP.NET Core attribute that enables a set of API-focused conventions for controller-based Web APIs. It is commonly applied to controllers that derive from ControllerBase, although it can also be applied through a shared base controller or at the assembly level.
This topic matters because [ApiController] changes how ASP.NET Core handles routing, model binding, validation, request body inference, service injection into action parameters, and error responses. These behaviors reduce boilerplate code, but they can also surprise developers who do not understand what the framework is doing automatically.
In real projects, [ApiController] is used in REST APIs, internal service APIs, microservices, backend-for-frontend APIs, and enterprise applications built with ASP.NET Core. It helps teams create consistent request validation and error response behavior without repeating if (!ModelState.IsValid) in every action.
This topic is important for interviews because it tests whether a candidate understands practical ASP.NET Core API behavior, not just how to write a controller action. Interviewers often ask about model validation, binding sources, ModelState, ValidationProblemDetails, ProblemDetails, request body binding, and why an action may return 400 Bad Request before the method body executes.
Core Concepts
What [ApiController] Is
[ApiController] is an attribute from ASP.NET Core MVC that enables opinionated Web API behaviors for controller-based APIs.
A typical controller looks like this:
using Microsoft.AspNetCore.Mvc;
[ApiController]
[Route("api/products")]
public class ProductsController : ControllerBase
{
[HttpGet("{id:int}")]
public ActionResult<ProductDto> GetById(int id)
{
var product = new ProductDto(id, "Keyboard", 49.99m);
return Ok(product);
}
}
public sealed record ProductDto(int Id, string Name, decimal Price);
The attribute helps ASP.NET Core treat the controller as an API controller rather than an MVC controller that returns views.
Key behaviors include:
- Attribute routing is required.
- Invalid model state can automatically return
400 Bad Request. - Binding sources can be inferred.
IFormFileandIFormFileCollectioninfer multipart form-data behavior.- Error responses can use
ProblemDetailsandValidationProblemDetails.
Where [ApiController] Can Be Applied
[ApiController] can be applied in three common ways.
Apply it directly to one controller:
[ApiController]
[Route("api/orders")]
public class OrdersController : ControllerBase
{
}
Apply it to a shared base controller:
[ApiController]
public abstract class ApiControllerBase : ControllerBase
{
}
[Route("api/customers")]
public class CustomersController : ApiControllerBase
{
}
Apply it at the assembly level:
using Microsoft.AspNetCore.Mvc;
[assembly: ApiController]
When applied at the assembly level, all controllers in the assembly behave as API controllers. This can be useful for API-only projects, but it also means individual controllers cannot easily opt out.
Attribute Routing Requirement
With [ApiController], attribute routing is expected. Actions should be reachable through attributes such as [Route], [HttpGet], [HttpPost], [HttpPut], and [HttpDelete].
Example:
[ApiController]
[Route("api/products")]
public class ProductsController : ControllerBase
{
[HttpGet("{id:int}")]
public IActionResult GetById(int id)
{
return Ok(new { Id = id });
}
}
This makes API routing explicit and easier to reason about. It also helps OpenAPI tools generate more accurate endpoint documentation.
A common mistake is to apply [ApiController] but rely only on conventional MVC routes. For Web APIs, explicit route attributes are the expected approach.
Automatic Model Validation
One of the most important [ApiController] behaviors is automatic model validation.
Without [ApiController], developers often write this manually:
[HttpPost]
public IActionResult Create(CreateProductRequest request)
{
if (!ModelState.IsValid)
{
return BadRequest(ModelState);
}
return Ok();
}
With [ApiController], the manual check is usually unnecessary. If model binding or validation produces an invalid ModelState, ASP.NET Core can automatically return 400 Bad Request before the action method body runs.
Example request model:
using System.ComponentModel.DataAnnotations;
public sealed class CreateProductRequest
{
[Required]
[StringLength(100)]
public string? Name { get; init; }
[Range(0.01, 100000)]
public decimal Price { get; init; }
}
Controller action:
[ApiController]
[Route("api/products")]
public class ProductsController : ControllerBase
{
[HttpPost]
public IActionResult Create(CreateProductRequest request)
{
// If request.Name is missing or Price is invalid,
// this code may not execute because ASP.NET Core can return 400 automatically.
return CreatedAtAction(nameof(GetById), new { id = 1 }, request);
}
[HttpGet("{id:int}")]
public IActionResult GetById(int id)
{
return Ok(new { Id = id });
}
}
This behavior makes APIs cleaner, but developers must understand that validation may happen before action code executes.
ModelState and Validation Errors
ModelState stores model binding and validation information.
It can contain errors from:
- Missing required request body.
- Invalid JSON.
- Type conversion failures.
- Data annotation validation failures.
- Custom validation attributes.
- Manual calls to
ModelState.AddModelError.
Example type conversion failure:
GET /api/products/abc
If the route expects id:int, ASP.NET Core cannot bind abc to an int. That failure can make ModelState invalid and result in a validation response.
Example manual model state error:
[HttpPost]
public IActionResult Create(CreateProductRequest request)
{
if (request.Name == "admin")
{
ModelState.AddModelError(nameof(request.Name), "Product name is not allowed.");
return ValidationProblem(ModelState);
}
return Ok();
}
Even with automatic validation, manual ModelState checks can still be useful for business-rule validation that happens inside the action. However, many teams prefer to separate business validation from model binding validation using validators, domain rules, or application-layer result types.
Default Validation Response
When [ApiController] returns an automatic validation response, the response typically uses ValidationProblemDetails.
Example response shape:
{
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
"title": "One or more validation errors occurred.",
"status": 400,
"errors": {
"Name": [
"The Name field is required."
],
"Price": [
"The field Price must be between 0.01 and 100000."
]
},
"traceId": "00-..."
}
Important points:
- The HTTP status is usually
400 Bad Request. - The response is machine-readable.
- The
errorsobject groups messages by field name or model key. - The response can include a trace identifier useful for support and logging.
- The shape is more consistent than returning arbitrary strings or anonymous objects.
ProblemDetails vs ValidationProblemDetails
ProblemDetails is a standard error response format for HTTP APIs. It commonly includes:
typetitlestatusdetailinstance
ValidationProblemDetails extends this idea for validation errors by adding an errors dictionary.
Use ProblemDetails for general API errors:
[HttpGet("{id:int}")]
public IActionResult GetById(int id)
{
return Problem(
title: "Product lookup failed.",
detail: $"Product {id} could not be loaded.",
statusCode: StatusCodes.Status500InternalServerError);
}
Use ValidationProblemDetails for validation failures:
[HttpPost]
public IActionResult Create(CreateProductRequest request)
{
ModelState.AddModelError(nameof(request.Name), "Name is already used.");
return ValidationProblem(ModelState);
}
A good interview answer should mention that validation failures and general API failures should have consistent response shapes, especially when consumed by frontend applications or other services.
Binding-Source Inference
Binding-source inference means ASP.NET Core can infer where action parameters should come from instead of requiring explicit attributes every time.
Common binding attributes include:
Example with explicit binding:
[HttpGet("{id:int}")]
public IActionResult Search(
[FromRoute] int id,
[FromQuery] string? keyword,
[FromHeader(Name = "X-Correlation-Id")] string? correlationId)
{
return Ok(new { id, keyword, correlationId });
}
With [ApiController], ASP.NET Core can infer many of these sources automatically.
Common Binding Inference Rules
For API controllers, binding-source inference commonly works like this:
- Complex types registered in the DI container are inferred as services.
- Complex types not registered in DI are usually inferred from the request body.
IFormFileandIFormFileCollectionare inferred from form data.- Parameters whose names match route parameters are inferred from route values.
- Other simple parameters are inferred from the query string.
Example:
[HttpPost("{tenantId:guid}/products")]
public IActionResult Create(
Guid tenantId,
CreateProductRequest request,
IProductService productService)
{
return Ok();
}
Possible inference:
tenantIdcomes from the route because the route template contains{tenantId}.requestcomes from the body because it is a complex request DTO.productServicemay come from services if it is registered in DI.
Even though inference is convenient, many teams still prefer explicit attributes for public APIs because they make the contract easier to read.
Simple Types vs Complex Types
ASP.NET Core treats simple and complex types differently.
Simple types include types such as:
intlongbooldecimaldoubleGuidDateTimestring- enums
Complex types include custom classes and records such as:
public sealed record CreateOrderRequest(
int CustomerId,
IReadOnlyList<CreateOrderLineRequest> Lines);
public sealed record CreateOrderLineRequest(
int ProductId,
int Quantity);
A common interview point is that simple action parameters are usually bound from route or query string, while complex request DTOs are usually bound from the request body in API controllers.
Example:
[HttpGet("search")]
public IActionResult Search(string keyword, int page = 1)
{
// keyword and page are usually read from query string:
// /api/products/search?keyword=keyboard&page=2
return Ok();
}
Request Body Binding and the One-Body-Parameter Rule
HTTP requests have one body stream. In ASP.NET Core MVC controllers, an action should not have multiple parameters bound from the body.
Problematic example:
[HttpPost]
public IActionResult Create(ProductRequest product, AuditRequest audit)
{
return Ok();
}
If both parameters are inferred or marked as [FromBody], ASP.NET Core cannot safely bind both from the same request body. The better design is to create one request DTO:
public sealed class CreateProductCommand
{
public ProductRequest Product { get; init; } = new();
public AuditRequest Audit { get; init; } = new();
}
[HttpPost]
public IActionResult Create(CreateProductCommand request)
{
return Ok();
}
Best practice:
- Use one body DTO per action.
- Keep route identifiers in route parameters.
- Keep filtering and pagination in query parameters.
- Avoid putting unrelated body parameters directly in the action signature.
[FromServices] and Dependency Injection in Action Parameters
[FromServices] binds an action parameter from the DI container.
Example:
[HttpGet("{id:int}")]
public async Task<IActionResult> GetById(
int id,
[FromServices] IProductQueryService productQueryService,
CancellationToken cancellationToken)
{
var product = await productQueryService.GetByIdAsync(id, cancellationToken);
return product is null ? NotFound() : Ok(product);
}
This can be useful for action-specific services, but constructor injection is often preferred for required controller dependencies.
Constructor injection:
[ApiController]
[Route("api/products")]
public class ProductsController : ControllerBase
{
private readonly IProductQueryService _productQueryService;
public ProductsController(IProductQueryService productQueryService)
{
_productQueryService = productQueryService;
}
[HttpGet("{id:int}")]
public async Task<IActionResult> GetById(int id, CancellationToken cancellationToken)
{
var product = await _productQueryService.GetByIdAsync(id, cancellationToken);
return product is null ? NotFound() : Ok(product);
}
}
Use constructor injection when the controller depends on the service for most actions. Use [FromServices] when the dependency is only needed by one action or when it improves action-level clarity.
Binding-Source Inference Can Surprise You
Binding-source inference is helpful, but it can create unexpected behavior.
Example:
[HttpPost]
public IActionResult Create(CreateProductRequest request, ProductOptions options)
{
return Ok();
}
If ProductOptions is registered in DI, it may be inferred from services. If it is not registered, it may be inferred from the body. That can change behavior depending on DI registration.
Better approach:
[HttpPost]
public IActionResult Create(
[FromBody] CreateProductRequest request,
[FromServices] ProductOptions options)
{
return Ok();
}
For interview answers, mention that explicit binding attributes are often clearer for complex or security-sensitive endpoints.
Validation Attributes and DTO Design
Data annotations are commonly used for request DTO validation.
Example:
using System.ComponentModel.DataAnnotations;
public sealed class RegisterUserRequest
{
[Required]
[EmailAddress]
public string? Email { get; init; }
[Required]
[StringLength(100, MinimumLength = 8)]
public string? Password { get; init; }
[Range(18, 120)]
public int Age { get; init; }
}
Controller:
[HttpPost("register")]
public IActionResult Register(RegisterUserRequest request)
{
return Ok();
}
Good DTO design practices:
- Use request-specific DTOs instead of exposing EF Core entities directly.
- Validate input shape at the API boundary.
- Keep domain validation in the domain or application layer.
- Avoid relying only on client-side validation.
- Avoid putting sensitive or server-controlled fields in request DTOs.
Automatic 400 vs Manual Business Validation
Automatic model validation is best for input shape and basic request validation.
Examples:
- Required field is missing.
- String length is too long.
- Numeric value is out of range.
- Request body is invalid.
- Type conversion failed.
Business validation is different.
Examples:
- User does not have enough balance.
- Product name must be unique in a tenant.
- Order cannot be canceled after shipment.
- Start date must be before end date based on business rules.
- Customer is not allowed to access the requested resource.
Business validation often belongs in the application layer or domain layer, not only in attributes.
Example:
[HttpPost]
public async Task<IActionResult> Create(
CreateProductRequest request,
CancellationToken cancellationToken)
{
var result = await _productService.CreateAsync(request, cancellationToken);
if (result.IsDuplicateName)
{
ModelState.AddModelError(nameof(request.Name), "A product with this name already exists.");
return ValidationProblem(ModelState);
}
return CreatedAtAction(nameof(GetById), new { id = result.ProductId }, null);
}
This approach still returns a validation-style response but keeps the business decision in the appropriate layer.
Customizing Automatic Validation Responses
You can customize automatic validation responses using ApiBehaviorOptions.
Example:
using Microsoft.AspNetCore.Mvc;
builder.Services.AddControllers()
.ConfigureApiBehaviorOptions(options =>
{
options.InvalidModelStateResponseFactory = context =>
{
var problemDetails = new ValidationProblemDetails(context.ModelState)
{
Title = "Request validation failed.",
Status = StatusCodes.Status400BadRequest,
Detail = "Check the errors property for details."
};
problemDetails.Extensions["traceId"] = context.HttpContext.TraceIdentifier;
return new BadRequestObjectResult(problemDetails);
};
});
This is useful when an organization needs a consistent API error contract across services.
Best practices:
- Keep the response machine-readable.
- Preserve field-level validation details.
- Include a trace identifier.
- Avoid leaking sensitive internal details.
- Keep the shape consistent across endpoints.
Disabling Automatic 400 Responses
Automatic 400 Bad Request responses can be disabled globally.
Example:
using Microsoft.AspNetCore.Mvc;
builder.Services.AddControllers()
.ConfigureApiBehaviorOptions(options =>
{
options.SuppressModelStateInvalidFilter = true;
});
After this, actions must check ModelState.IsValid manually or use another validation mechanism.
Disabling automatic validation is uncommon for standard APIs, but it may be useful when:
- The team uses a custom validation pipeline.
- The API must return a legacy error response shape.
- The application wants to combine multiple validation sources before responding.
- The endpoint needs special validation flow.
Disabling Binding-Source Inference
Binding-source inference can also be disabled.
Example:
builder.Services.AddControllers()
.ConfigureApiBehaviorOptions(options =>
{
options.SuppressInferBindingSourcesForParameters = true;
});
If inference is disabled, developers should use explicit attributes:
[HttpPost("{tenantId:guid}/products")]
public IActionResult Create(
[FromRoute] Guid tenantId,
[FromBody] CreateProductRequest request,
[FromServices] IProductService productService)
{
return Ok();
}
This style is more verbose but can make API contracts clearer.
Problem Details for Client Error Responses
With API controller conventions, client error results such as NotFound() can be mapped to ProblemDetails responses.
Example:
[HttpGet("{id:int}")]
public IActionResult GetById(int id)
{
ProductDto? product = null;
if (product is null)
{
return NotFound();
}
return Ok(product);
}
Instead of returning an empty 404, the framework may generate a problem details response depending on configuration.
For APIs consumed by frontend applications, this consistency helps client code handle errors in a predictable way.
Multipart Form-Data and File Uploads
[ApiController] applies special inference for file upload parameters such as IFormFile and IFormFileCollection.
Example:
[HttpPost("upload")]
public async Task<IActionResult> Upload(IFormFile file, CancellationToken cancellationToken)
{
if (file.Length == 0)
{
return BadRequest("File is empty.");
}
await using var stream = file.OpenReadStream();
// Save or process the stream here.
return Ok(new { file.FileName, file.Length });
}
For file uploads, clients usually send multipart/form-data.
Common mistakes:
- Sending JSON instead of multipart form data.
- Forgetting request size limits.
- Loading very large files entirely into memory.
- Trusting the uploaded file name without sanitization.
- Not validating file type, extension, and content.
Nullable Reference Types and Validation
Nullable reference types and validation attributes are related but not identical.
Example:
public sealed class CreateCustomerRequest
{
public string Name { get; init; } = string.Empty;
public string? Notes { get; init; }
}
With nullable reference types enabled, Name expresses that the property should not be null in C# code. However, API validation behavior depends on model binding and validation rules.
Many teams still use explicit validation attributes for public API contracts:
public sealed class CreateCustomerRequest
{
[Required]
public string? Name { get; init; }
public string? Notes { get; init; }
}
Best practice:
- Use nullable reference types for C# correctness.
- Use validation attributes or a validation library for API input rules.
- Avoid assuming nullable annotations alone are enough for all runtime validation needs.
Minimal APIs Comparison
[ApiController] applies to controller-based APIs. Minimal APIs do not use [ApiController] on endpoint handlers, but they have their own binding and validation patterns.
Controller example:
[ApiController]
[Route("api/products")]
public class ProductsController : ControllerBase
{
[HttpPost]
public IActionResult Create(CreateProductRequest request)
{
return Ok();
}
}
Minimal API example:
app.MapPost("/api/products", (CreateProductRequest request) =>
{
return Results.Ok();
});
Important difference:
[ApiController]is a controller convention.- Minimal APIs use endpoint parameter binding rules.
- Validation behavior in Minimal APIs may need explicit filters, endpoint filters, libraries, or manual handling depending on the application design.
In interviews, avoid saying [ApiController] controls all ASP.NET Core APIs. It specifically affects controller-based APIs.
Common Mistakes
Common mistakes include:
- Manually checking
ModelState.IsValidin every action even though[ApiController]already handles it. - Forgetting that invalid model state can prevent the action method from running.
- Using multiple
[FromBody]parameters in one action. - Assuming all complex parameters always come from the body, even when registered in DI.
- Returning inconsistent validation error shapes.
- Exposing domain entities or EF Core entities directly as request models.
- Treating data annotations as complete business validation.
- Not including trace or correlation information in error responses.
- Disabling automatic validation without replacing it with a consistent validation strategy.
- Forgetting explicit route attributes.
Best Practices
Use [ApiController] for controller-based Web APIs unless there is a strong reason not to.
Prefer ControllerBase instead of Controller for APIs that do not return views.
Use explicit route attributes:
[Route("api/orders")]
Use request and response DTOs instead of domain entities:
public sealed record CreateOrderRequest(int CustomerId, List<CreateOrderLineRequest> Lines);
public sealed record OrderResponse(int Id, int CustomerId, decimal Total);
Use one request body DTO per action.
Use explicit binding attributes when the action signature could be ambiguous:
public IActionResult Create(
[FromRoute] Guid tenantId,
[FromBody] CreateOrderRequest request,
[FromHeader(Name = "X-Correlation-Id")] string? correlationId)
Return ValidationProblem(ModelState) for manual validation errors that should match automatic validation responses.
Customize InvalidModelStateResponseFactory if your organization requires a standard error envelope.
Keep input validation, business validation, authorization, and exception handling separate.