What interviewers test about controllers
Controllers are where most of an ASP.NET Core API's behavior lives — model binding, validation, response shaping, and action filters all feed through them. Interviewers test the nuances because they reveal whether a developer understands the framework's conventions or is just following copy-pasted patterns.
Controller vs ControllerBase — pick the right base
// ControllerBase — for Web APIs (no Razor View support):
[ApiController]
[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
// Ok(), NotFound(), BadRequest(), Created()... all available
// View() does NOT exist here
[HttpGet]
public IActionResult List() => Ok(_products);
}
// Controller — for MVC apps with Razor Views:
public class HomeController : Controller
{
public IActionResult Index()
{
ViewData["Title"] = "Home";
return View(); // renders Views/Home/Index.cshtml
}
[HttpPost]
public IActionResult Create(Product p)
{
if (!ModelState.IsValid)
return View(p); // re-render form with errors
_repo.Add(p);
return RedirectToAction(nameof(Index));
}
}
Controller adds: View(), PartialView(), ViewData, ViewBag, TempData,
RedirectToAction. For pure APIs, these are dead weight — use ControllerBase.
What ApiController actually does
[ApiController] on a controller class (or applied assembly-wide with
[assembly: ApiController]) enables four behaviors:
1. Automatic model state validation — 400 before the action runs:
// Without [ApiController]:
[HttpPost]
public IActionResult Create([FromBody] CreateOrderDto dto)
{
if (!ModelState.IsValid) // must check manually
return BadRequest(ModelState);
var order = _svc.Create(dto);
return Created($"/orders/{order.Id}", order);
}
// With [ApiController]:
[HttpPost]
public IActionResult Create([FromBody] CreateOrderDto dto)
{
// If dto fails validation, 400 is returned automatically — this line is never reached
var order = _svc.Create(dto);
return Created($"/orders/{order.Id}", order);
}
2. Binding source inference:
[HttpGet("{id}")]
public IActionResult Get(int id, // [FromRoute] inferred
string? sort) // [FromQuery] inferred — GET has no body
[HttpPost]
public IActionResult Create(CreateDto dto) // [FromBody] inferred — complex type, POST
3. Problem Details response format (RFC 7807):
// Automatic 400 response:
// { "type": "...", "title": "One or more validation errors occurred.",
// "status": 400, "errors": { "Email": ["The Email field is required."] } }
4. Attribute routing required — conventional routes are disabled for the controller.
IActionResult vs ActionResult<T> vs T
// IActionResult — flexible, but OpenAPI/Swagger can't infer the response body type:
[HttpGet("{id}")]
public IActionResult Get(int id)
{
var p = _repo.Find(id);
return p is null ? NotFound() : Ok(p); // Ok() wraps Product but returns IActionResult
}
// ActionResult<T> — preferred: Swagger generates accurate Product schema,
// and implicit conversion keeps code clean:
[HttpGet("{id}")]
public ActionResult<Product> Get(int id)
{
var p = _repo.Find(id);
return p is null ? NotFound() : p; // Product implicitly converts to ActionResult<Product>
}
// T directly — simplest; always 200 OK, no way to return error codes:
[HttpGet("all")]
public IEnumerable<Product> List() => _repo.GetAll();
ActionResult<T> wins for most API actions — it keeps the return type clear for tooling
while still allowing NotFound(), BadRequest(), etc.
Model binding — where values come from
[HttpGet("search")]
public IActionResult Search(
string term, // [FromQuery] ?term=shoes
int page = 1, // [FromQuery] with default
[FromHeader(Name = "X-Locale")] string locale = "en") // header
=> Ok(_svc.Search(term, page, locale));
[HttpPost]
public IActionResult Create([FromBody] CreateProductDto dto) // JSON body
=> Created($"/products/{dto.Id}", dto);
[HttpPost("upload")]
public IActionResult Upload(
[FromForm] string category,
IFormFile file) // multipart form upload
{
using var stream = file.OpenReadStream();
_importer.Import(stream, category);
return Ok();
}
[ApiController] inference rules (when no explicit [From*] is provided):
- Route parameter name matches →
[FromRoute] IFormFile/IFormFileCollection→[FromForm]- Complex type on POST/PUT/PATCH →
[FromBody] - Simple type on GET or unmatched →
[FromQuery]
Model validation with Data Annotations
public class RegisterDto
{
[Required(ErrorMessage = "Email is required")]
[EmailAddress]
public string Email { get; set; } = default!;
[Required]
[StringLength(100, MinimumLength = 8)]
public string Password { get; set; } = default!;
[Range(18, 120)]
public int Age { get; set; }
[Url]
public string? Website { get; set; }
}
Custom validation attribute:
public class FutureDateAttribute : ValidationAttribute
{
protected override ValidationResult? IsValid(object? value, ValidationContext ctx)
=> value is DateTime dt && dt > DateTime.Today
? ValidationResult.Success
: new ValidationResult("Date must be in the future");
}
Cross-property validation with IValidatableObject:
public class DateRangeDto : IValidatableObject
{
public DateTime Start { get; set; }
public DateTime End { get; set; }
public IEnumerable<ValidationResult> Validate(ValidationContext ctx)
{
if (End <= Start)
yield return new ValidationResult("End must be after Start",
new[] { nameof(End) });
}
}
Important architecture note: Data Annotations validate input format, not business
rules. An email address field with [EmailAddress] validates the format — it doesn't check
if that email is already registered. Business validation (duplicates, business rules) belongs
in your service layer, not the DTO.
Action result helper methods
// 2xx:
return Ok(product); // 200 with JSON body
return Created($"/products/{id}", product); // 201 Created + Location header
return CreatedAtAction(nameof(Get), new { id }, product); // 201 with route-based Location
return Accepted(); // 202 Accepted (async operation)
return NoContent(); // 204 No Content
// 4xx:
return BadRequest(ModelState); // 400 with validation errors
return Unauthorized(); // 401
return Forbid(); // 403
return NotFound(); // 404
return Conflict("Duplicate key"); // 409
return UnprocessableEntity(errors); // 422
// RFC 7807 Problem Details:
return Problem(title: "Not Found", detail: $"Order {id} not found", statusCode: 404);
return ValidationProblem(ModelState); // structured 400 for model errors
// File download:
return File(bytes, "application/pdf", "document.pdf");
Always use these helper methods rather than setting Response.StatusCode directly — they
produce properly typed IActionResult objects that participate correctly in content
negotiation and result filters.
Action filters — adding cross-cutting behavior
// Synchronous filter:
public class AuditFilter : IActionFilter
{
private readonly IAuditService _audit;
public AuditFilter(IAuditService audit) => _audit = audit;
public void OnActionExecuting(ActionExecutingContext context)
=> _audit.LogStart(context.ActionDescriptor.DisplayName,
context.ActionArguments);
public void OnActionExecuted(ActionExecutedContext context)
=> _audit.LogEnd(context.ActionDescriptor.DisplayName,
(context.Result as ObjectResult)?.StatusCode);
}
// Async filter that can short-circuit:
public class EnsureProductExistsFilter : IAsyncActionFilter
{
private readonly IProductRepo _repo;
public EnsureProductExistsFilter(IProductRepo repo) => _repo = repo;
public async Task OnActionExecutionAsync(
ActionExecutingContext context, ActionExecutionDelegate next)
{
if (context.ActionArguments.TryGetValue("id", out var idObj)
&& idObj is int id
&& !await _repo.ExistsAsync(id))
{
context.Result = new NotFoundResult(); // short-circuit
return;
}
await next(); // proceed to action
}
}
// Apply globally — runs on every action:
builder.Services.AddControllers(o =>
o.Filters.Add<AuditFilter>());
// Apply per-controller using DI:
[ServiceFilter(typeof(EnsureProductExistsFilter))]
public class ProductsController : ControllerBase { ... }
// Apply per-action as attribute (must implement IFilterFactory or derive from ActionFilterAttribute):
[HttpGet("{id:int}")]
[ServiceFilter(typeof(EnsureProductExistsFilter))]
public IActionResult Get(int id) => Ok(_repo.Find(id));
Filter execution order within an action call:
Authorization → Resource → Action → Result → Exception
Content negotiation
By default ASP.NET Core serializes everything as JSON. Adding XML support:
builder.Services.AddControllers()
.AddXmlSerializerFormatters();
// Client sends: Accept: application/xml
// → response is serialized as XML
Constrain and document accepted/produced formats:
[HttpGet("{id}")]
[Produces("application/json")]
[ProducesResponseType<Product>(200)]
[ProducesResponseType(404)]
public ActionResult<Product> Get(int id) { ... }
Without [ProducesResponseType], Swagger shows "200 Undocumented" and cannot generate
accurate client SDKs.
Async actions — the right pattern
// Async I/O — thread released while awaiting DB:
[HttpGet]
public async Task<ActionResult<IEnumerable<Product>>> List(CancellationToken ct)
{
var products = await _db.Products
.AsNoTracking()
.ToListAsync(ct); // non-blocking; ct cancels if client disconnects
return Ok(products);
}
// Propagate CancellationToken to every I/O call:
[HttpGet("{id:int}")]
public async Task<ActionResult<Product>> Get(int id, CancellationToken ct)
{
var p = await _repo.FindAsync(id, ct);
return p is null ? NotFound() : Ok(p);
}
// Never block async code:
[HttpGet("bad")]
public IActionResult GetBad()
{
var p = _repo.FindAsync(1).Result; // blocks thread — kills scalability!
return Ok(p);
}
ASP.NET Core binds CancellationToken parameters automatically from the HTTP request's
abort token — when the client disconnects, the token fires, and your DB queries cancel.
This prevents wasted work on abandoned requests.
Recap
Inherit ControllerBase for APIs, Controller for MVC views. Add [ApiController] to get
automatic model state validation, binding inference, and Problem Details responses. Prefer
ActionResult<T> over IActionResult for accurate OpenAPI schemas. Use [ProducesResponseType]
on every action. Model binding pulls values from route, query, body, form, and headers — with
[ApiController], source inference handles most cases automatically. Use Data Annotations for
format validation; put business rules in the service layer. Make actions async and accept
CancellationToken for any I/O operation.