Skip to content

.NET Core · ASP.NET Core

ASP.NET Core Controllers, Model Binding, and Action Filters

7 min read Updated 2026-06-23 Share:

Practice Controllers & Actions interview questions

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.

More ways to practice

The self-quiz is live. Get notified when mock interviews and new question packs drop.

or
Join our WhatsApp Channel