Skip to content

Controllers & Actions Interview Questions & Answers

15 questions Updated 2026-06-23 Share:

ASP.NET Core controllers interview questions — IActionResult, model binding, the ApiController attribute, validation, action filters, and content negotiation.

Read the in-depth guideASP.NET Core Controllers, Model Binding, and Action Filters(opens in new tab)
15 of 15

A controller is a class that groups related HTTP endpoint handlers (actions). ASP.NET Core identifies a controller by convention or attribute:

  • The class name ends in Controller, or
  • The class is decorated with [Controller], or
  • The class inherits from Controller or ControllerBase.
// Inheriting ControllerBase — most common for APIs
[ApiController]
[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
    private readonly IProductService _svc;

    // Dependencies injected via constructor (DI):
    public ProductsController(IProductService svc) => _svc = svc;

    [HttpGet]
    public async Task<IActionResult> List()
    {
        var products = await _svc.GetAllAsync();
        return Ok(products); // 200 OK with JSON body
    }

    [HttpGet("{id:int}")]
    public async Task<IActionResult> Get(int id)
    {
        var p = await _svc.FindAsync(id);
        return p is null ? NotFound() : Ok(p);
    }
}

ControllerBase provides helpers (Ok, NotFound, BadRequest, etc.) and model binding. Controller extends ControllerBase with Razor View support (View(), ViewData, TempData).

Rule of thumb: Inherit from ControllerBase for Web APIs (no views). Inherit from Controller only for MVC apps that render Razor Views.

  • IActionResult — interface; allows returning any response (200, 404, 400, etc.) but loses compile-time type information for the success body.
  • ActionResult<T> — generic wrapper; allows returning both a typed result (T) or any IActionResult (errors), and enables accurate OpenAPI schema generation.
  • T directly — simplest; always returns 200 OK with the serialized object. No built-in way to return 404 or 400.
// IActionResult — flexible but no type info in OpenAPI:
[HttpGet("{id}")]
public IActionResult GetV1(int id)
{
    var p = _repo.Find(id);
    return p is null ? NotFound() : Ok(p); // Ok() wraps Product, but return type is IActionResult
}

// ActionResult<T> — preferred for APIs; swagger shows Product schema:
[HttpGet("{id}")]
public ActionResult<Product> GetV2(int id)
{
    var p = _repo.Find(id);
    return p is null ? NotFound() : p; // implicit conversion Product → ActionResult<Product>
}

// T directly — minimal API style; no error responses possible:
[HttpGet("all")]
public IEnumerable<Product> List() => _repo.GetAll();
// Always 200 OK; can't return 404 without throwing an exception

With [ApiController], ActionResult<T> also feeds ProducesResponseType inference.

Rule of thumb: Use ActionResult<T> for API actions that can return both a typed success response and error responses. Use T directly only for actions that always succeed and you are confident will never need to return an error status.

[ApiController] enables several opinionated API behaviors that reduce boilerplate and enforce best practices:

  1. Automatic model validation — if ModelState is invalid, automatically returns 400 Bad Request with a ValidationProblemDetails body.
  2. Binding source inference — infers [FromBody], [FromRoute], [FromQuery] without explicit attributes.
  3. Problem Details for error responses — 400/404 etc. are wrapped in RFC 7807 ProblemDetails JSON.
  4. Attribute routing required — conventional routing is disabled; every action must have a route attribute.
[ApiController]
[Route("api/[controller]")]
public class OrdersController : ControllerBase
{
    [HttpPost]
    public IActionResult Create([FromBody] CreateOrderDto dto)
    {
        // WITHOUT [ApiController]: you'd need to check ModelState manually:
        // if (!ModelState.IsValid) return BadRequest(ModelState);

        // WITH [ApiController]: ModelState.IsValid check is automatic.
        // If dto.Name is missing, 400 is returned before this line runs:
        var order = _svc.Create(dto);
        return Created($"/api/orders/{order.Id}", order);
    }
}

// DTO with validation attributes:
public class CreateOrderDto
{
    [Required]
    [StringLength(100)]
    public string ProductName { get; set; } = default!;

    [Range(1, 1000)]
    public int Quantity { get; set; }
}

Apply at the assembly level to avoid repeating on every controller:

[assembly: ApiController]

Rule of thumb: Always add [ApiController] to Web API controllers. It reduces boilerplate and enforces conventions that make your API predictable. Do not apply it to MVC controllers that return Views.

Model binding extracts values from the HTTP request (route data, query string, form data, body, headers) and converts them into action method parameter types. It runs before the action method and populates ModelState.

[HttpGet("search")]
public IActionResult Search(
    string term,           // [FromQuery] inferred: ?term=shoes
    int page = 1,          // [FromQuery] with default
    int pageSize = 20)     // [FromQuery] with default
    => Ok(_svc.Search(term, page, pageSize));

[HttpPost]
public IActionResult Create([FromBody] CreateProductDto dto)
    // Body JSON is deserialized into CreateProductDto
    => Created($"/{dto.Id}", dto);

[HttpPost("import")]
public IActionResult Import(
    [FromForm] string category,   // form field
    IFormFile file)               // uploaded file
{
    using var stream = file.OpenReadStream();
    _importer.Import(stream, category);
    return Ok();
}

Custom model binder:

// Bind a comma-separated query string to a List<int>:
public class CommaSeparatedListBinder : IModelBinder
{
    public Task BindModelAsync(ModelBindingContext ctx)
    {
        var value = ctx.ValueProvider.GetValue(ctx.ModelName).FirstValue;
        if (string.IsNullOrEmpty(value)) { ctx.Result = ModelBindingResult.Success(null); return Task.CompletedTask; }
        var result = value.Split(',').Select(int.Parse).ToList();
        ctx.Result = ModelBindingResult.Success(result);
        return Task.CompletedTask;
    }
}

[HttpGet("filter")]
public IActionResult Filter(
    [ModelBinder(typeof(CommaSeparatedListBinder))] List<int> ids)
    // ?ids=1,2,3 → ids = [1, 2, 3]
    => Ok(ids);

Rule of thumb: Rely on default binding for standard cases. Use explicit [From*] attributes when you need to override the inferred source or when not using [ApiController].

.NET's Data Annotations validate input before the action runs. ModelState accumulates the results; [ApiController] automatically returns 400 if invalid.

using System.ComponentModel.DataAnnotations;

public class RegisterDto
{
    [Required(ErrorMessage = "Email is required")]
    [EmailAddress]
    public string Email { get; set; } = default!;

    [Required]
    [StringLength(100, MinimumLength = 8,
        ErrorMessage = "Password must be 8–100 characters")]
    public string Password { get; set; } = default!;

    [Range(18, 120, ErrorMessage = "Age must be between 18 and 120")]
    public int Age { get; set; }

    [Url]
    public string? Website { get; set; }
}

[HttpPost("register")]
public IActionResult Register([FromBody] RegisterDto dto)
{
    // [ApiController] returns 400 automatically if ModelState is invalid.
    // Without [ApiController]:
    if (!ModelState.IsValid)
        return BadRequest(ModelState);

    _svc.Register(dto);
    return Ok();
}

Custom validation attribute:

public class FutureDateAttribute : ValidationAttribute
{
    protected override ValidationResult? IsValid(
        object? value, ValidationContext ctx)
    {
        if (value is DateTime dt && dt > DateTime.Today)
            return ValidationResult.Success;
        return new ValidationResult("Date must be in the future");
    }
}

public class EventDto
{
    [FutureDate]
    public DateTime StartDate { get; set; }
}

For complex cross-property validation, implement 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) });
    }
}

Rule of thumb: Use Data Annotations for simple, attribute-level validation. Implement IValidatableObject for cross-property rules. For complex domain validation (database lookups, business rules), validate in your service layer, not on the DTO.

Action filters are attributes that run code before (OnActionExecuting) and after (OnActionExecuted) an action method. They have access to the full MVC context — action arguments, ModelState, and the ActionResult.

// Synchronous filter:
public class LoggingFilter : IActionFilter
{
    private readonly ILogger _logger;
    public LoggingFilter(ILogger<LoggingFilter> logger) => _logger = logger;

    public void OnActionExecuting(ActionExecutingContext context)
    {
        _logger.LogInformation("Executing {Action} with args: {Args}",
            context.ActionDescriptor.DisplayName,
            context.ActionArguments);
    }

    public void OnActionExecuted(ActionExecutedContext context)
    {
        _logger.LogInformation("Executed {Action} → {StatusCode}",
            context.ActionDescriptor.DisplayName,
            (context.Result as ObjectResult)?.StatusCode);
    }
}

// Async filter (preferred when awaiting inside):
public class ValidateModelFilter : IAsyncActionFilter
{
    public async Task OnActionExecutionAsync(
        ActionExecutingContext context, ActionExecutionDelegate next)
    {
        if (!context.ModelState.IsValid)
        {
            context.Result = new BadRequestObjectResult(context.ModelState);
            return; // short-circuit — do NOT call next
        }
        await next(); // run the action
    }
}

// Apply globally:
builder.Services.AddControllers(o =>
{
    o.Filters.Add<LoggingFilter>();
    o.Filters.Add<ValidateModelFilter>();
});

// Apply to a controller or action:
[ServiceFilter(typeof(LoggingFilter))] // resolves from DI
public class OrdersController : ControllerBase { ... }

Filter execution order: Authorization → Resource → Action → Result → Exception.

Rule of thumb: Use action filters for concerns that need MVC context (model state, action arguments). Use middleware for concerns that apply to all HTTP traffic regardless of whether it reaches a controller.

Content negotiation is the process of selecting the response format (JSON, XML, etc.) based on the client's Accept header. ASP.NET Core defaults to JSON via System.Text.Json but can be extended.

// Add XML formatter alongside JSON:
builder.Services.AddControllers()
    .AddXmlSerializerFormatters(); // adds application/xml support

// Client sends: Accept: application/xml
// → response serialized as XML instead of JSON

// Force JSON regardless of Accept header:
builder.Services.AddControllers(o =>
    o.RespectBrowserAcceptHeader = false); // default: false → always JSON

// ProducesResponseType + Produces: document and constrain formats
[HttpGet("{id}")]
[Produces("application/json")]                 // only produce JSON
[ProducesResponseType<Product>(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public ActionResult<Product> Get(int id) { ... }

Switching to Newtonsoft.Json (if you need features STJ doesn't support):

builder.Services.AddControllers()
    .AddNewtonsoftJson(o =>
        o.SerializerSettings.NullValueHandling = Newtonsoft.Json.NullValueHandling.Ignore);

Custom output formatter (e.g., CSV):

public class CsvOutputFormatter : TextOutputFormatter
{
    public CsvOutputFormatter()
    {
        SupportedMediaTypes.Add("text/csv");
        SupportedEncodings.Add(Encoding.UTF8);
    }

    protected override bool CanWriteType(Type? type) => type == typeof(IEnumerable<Product>);

    public override async Task WriteResponseBodyAsync(
        OutputFormatterWriteContext ctx, Encoding selectedEncoding)
    {
        var products = (IEnumerable<Product>)ctx.Object!;
        var writer = ctx.HttpContext.Response.BodyWriter;
        await writer.WriteAsync(/* CSV bytes */ Encoding.UTF8.GetBytes("..."));
    }
}

builder.Services.AddControllers(o => o.OutputFormatters.Add(new CsvOutputFormatter()));

Rule of thumb: Leave content negotiation at its default (JSON) unless clients explicitly need XML or custom formats. Document supported formats with [Produces] for accurate OpenAPI specs.

ControllerBase provides the HTTP-related helpers (Ok, NotFound, BadRequest, Created, File, etc.) and model binding. It has no Razor View support.

Controller inherits ControllerBase and adds:

  • View(), PartialView(), Json() for rendering Razor Views
  • ViewData, ViewBag, TempData dictionaries
  • RedirectToAction, RedirectToRoute
// Web API — no views needed:
[ApiController]
[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
    [HttpGet]
    public IActionResult List() => Ok(_products); // returns JSON
}

// MVC — returns Razor views:
public class HomeController : Controller
{
    public IActionResult Index()
    {
        ViewData["Title"] = "Home";
        return View(); // renders Views/Home/Index.cshtml
    }

    public IActionResult Details(int id)
    {
        var product = _repo.Find(id);
        return View(product); // passes model to view
    }

    [HttpPost]
    public IActionResult Create(Product p)
    {
        if (!ModelState.IsValid)
            return View(p); // re-render form with validation errors
        _repo.Add(p);
        return RedirectToAction(nameof(Index));
    }
}

Rule of thumb: Use ControllerBase for Web APIs (returns JSON/data). Use Controller for MVC apps that render HTML views. Never return View() from a class that inherits only ControllerBase — that method doesn't exist there.

[ProducesResponseType] is a documentation attribute that declares which HTTP status codes an action can return and what type the response body has. It is used by tools like Swashbuckle (Swagger) to generate accurate OpenAPI specs.

[HttpGet("{id:int}")]
[ProducesResponseType<Product>(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public ActionResult<Product> Get(int id)
{
    if (id <= 0) return BadRequest("Id must be positive");
    var p = _repo.Find(id);
    return p is null ? NotFound() : Ok(p);
}

// Generic form (C# 11 / .NET 7+) — avoids typeof:
[ProducesResponseType<Product>(200)]
[ProducesResponseType<ProblemDetails>(404)]

// Or use [ProducesDefaultResponseType] for catch-all:
[ProducesDefaultResponseType(typeof(ProblemDetails))]

Without [ProducesResponseType], Swagger shows 200 undocumented which makes client SDK generation guess or produce incorrect models.

Apply globally with a convention to avoid repetition:

builder.Services.AddControllers(o =>
    o.Conventions.Add(new ApiExplorerConvention()));
// Or use [ProducesResponseType] on the controller class to default for all actions.

Rule of thumb: Annotate every API action with [ProducesResponseType] for each status code it can return. This keeps your OpenAPI spec accurate, improves client SDK generation, and documents the contract for API consumers.

ControllerBase provides factory methods that create IActionResult instances without manually instantiating result classes:

// 2xx — success:
return Ok(data);                   // 200 OK with body
return Created("/uri", data);      // 201 Created with Location header + body
return CreatedAtAction(nameof(Get), new { id }, data); // 201 with route-based Location
return Accepted();                 // 202 Accepted (async operation started)
return NoContent();                // 204 No Content (successful, no body)

// 3xx — redirect:
return RedirectToAction("Index");  // 302 to action
return RedirectToRoute("named");   // 302 to named route
return LocalRedirect("/path");     // 302, validates local URL (prevents open redirect)

// 4xx — client error:
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 Unprocessable Entity

// 5xx:
return StatusCode(503, "Maintenance"); // arbitrary status with body

// File responses:
return File(bytes, "image/png");
return PhysicalFile("/path/file.pdf", "application/pdf", "download.pdf");
return Stream(stream, "application/octet-stream");

The RFC 7807 Problem Details shortcut:

return Problem(
    title: "Not Found",
    detail: $"Product {id} does not exist",
    statusCode: 404);
// → {"type":"...","title":"Not Found","status":404,"detail":"..."}

Rule of thumb: Always use the named helper methods (Ok, NotFound, etc.) instead of manually setting Response.StatusCode — they create properly typed IActionResult objects and work correctly with the MVC result pipeline.

Actions that perform I/O (database, HTTP, file) should be async Task<IActionResult> so the server thread is freed while waiting. Synchronous actions are fine only for truly synchronous in-memory operations.

// Good: async I/O — thread is free while awaiting DB
[HttpGet]
public async Task<ActionResult<IEnumerable<Product>>> List(CancellationToken ct)
{
    var products = await _dbContext.Products
        .AsNoTracking()
        .ToListAsync(ct); // non-blocking DB query
    return Ok(products);
}

// Good: accept CancellationToken for client disconnects
[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);
}

// Avoid: blocking async over sync
[HttpGet("bad")]
public IActionResult GetBad()
{
    var result = _repo.FindAsync(1).Result; // blocks a thread — avoid!
    return Ok(result);
}

// Fine: synchronous in-memory ops — no async needed
[HttpGet("version")]
public IActionResult Version() => Ok("1.0.0");

ASP.NET Core automatically binds CancellationToken parameters from the request abort token — when the client disconnects, the token is cancelled, allowing DB queries and HTTP calls to be cancelled.

Rule of thumb: Make actions async Task<IActionResult> whenever they call any I/O. Accept CancellationToken ct as the last parameter and forward it to every awaitable call so the server can cancel orphaned requests.

Problem Details (RFC 7807 / RFC 9457) is a standard JSON format for HTTP API error responses. ASP.NET Core 7+ includes built-in Problem Details support via IProblemDetailsService and the Problem() helper method.

// Enable Problem Details for all error responses:
builder.Services.AddProblemDetails(); // registers IProblemDetailsService

// Use it in your exception handler:
app.UseExceptionHandler();            // uses ProblemDetails format automatically

// Return Problem Details from an action:
[HttpGet("{id:int}")]
public ActionResult<Order> Get(int id)
{
    var order = _repo.Find(id);
    if (order is null)
        return Problem(
            title:      "Order not found",
            detail:     $"No order with id {id} exists.",
            statusCode: StatusCodes.Status404NotFound,
            type:       "https://tools.ietf.org/html/rfc9110#section-15.5.5");

    return Ok(order);
}

// [ApiController] wraps 400 responses as ValidationProblemDetails automatically:
// POST with invalid body → 400:
// {
//   "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."] }
// }

// Customize the Problem Details response globally:
builder.Services.AddProblemDetails(opts =>
{
    opts.CustomizeProblemDetails = ctx =>
    {
        ctx.ProblemDetails.Extensions["traceId"] =
            ctx.HttpContext.TraceIdentifier; // add correlation id
    };
});

Rule of thumb: Enable AddProblemDetails() and use the Problem() helper for all error responses in Web APIs. This gives API consumers a consistent, machine-readable error format across every endpoint, and enables accurate OpenAPI error schema generation.

ControllerBase provides several methods to return file content as a download or inline response. Choose the method that matches your data source.

// 1. Byte array in memory — small files:
[HttpGet("receipt/{id:int}")]
public IActionResult DownloadReceipt(int id)
{
    byte[] pdfBytes = _reportService.GeneratePdf(id);
    return File(
        fileContents:   pdfBytes,
        contentType:    "application/pdf",
        fileDownloadName: $"receipt-{id}.pdf"); // triggers Save As dialog
}

// 2. Stream — large files (memory-efficient):
[HttpGet("export")]
public async Task<IActionResult> ExportCsv(CancellationToken ct)
{
    var stream = await _exportService.CreateCsvStreamAsync(ct);
    return File(stream, "text/csv", "export.csv");
    // ASP.NET Core disposes the stream after sending
}

// 3. Physical file path on disk:
[HttpGet("manual/{filename}")]
public IActionResult GetManual(string filename)
{
    var path = Path.Combine(_env.WebRootPath, "manuals", filename);
    if (!System.IO.File.Exists(path))
        return NotFound();

    return PhysicalFile(
        physicalPath:    path,
        contentType:     "application/pdf",
        fileDownloadName: filename,
        enableRangeProcessing: true); // supports partial content (video streaming)
}

// 4. Virtual path (served from IFileProvider):
[HttpGet("logo")]
public IActionResult GetLogo() =>
    VirtualFile("~/images/logo.png", "image/png");
// Note: omit fileDownloadName to serve inline (browser displays instead of downloads)

Rule of thumb: Use File(stream, ...) for large files to avoid loading them fully into memory. Set enableRangeProcessing: true for media files. Omit fileDownloadName to render inline (e.g., images); include it to prompt a download.

ASP.NET Core controllers provide several redirect methods, each mapping to a different HTTP status code and target type. Choosing the right one prevents open-redirect vulnerabilities and incorrect caching by clients.

// 301 Permanent redirect — browser/search engines cache forever:
return RedirectPermanent("https://new-site.example.com");

// 302 Temporary redirect — default; browser does not cache:
return Redirect("https://external.example.com/checkout");

// 302 to another action (safe — uses route generation, no hard-coded URL):
return RedirectToAction(nameof(Index), "Home");
return RedirectToAction(nameof(Get), new { id = newId });

// 301 to another action:
return RedirectToActionPermanent(nameof(Index));

// 302 to a named route:
return RedirectToRoute("order-details", new { id = order.Id });

// Local redirect — validates the URL is local (prevents open redirect attacks):
[HttpPost("login")]
public IActionResult Login(string returnUrl)
{
    // Bad: Redirect(returnUrl) — returnUrl could be https://evil.com
    // Good: LocalRedirect validates the URL starts with /
    return LocalRedirect(returnUrl ?? "/dashboard");
    // Throws InvalidOperationException if returnUrl is not local
}

// 307/308 — preserve HTTP method across redirect (POST stays POST):
return RedirectToActionPreserveMethod(nameof(Create)); // 307
return RedirectToActionPermanentPreserveMethod(nameof(Create)); // 308

Rule of thumb: Always use LocalRedirect when redirecting to a caller-supplied URL to prevent open-redirect vulnerabilities. Use RedirectToAction instead of hard-coded strings — it stays correct when routes change.

[Consumes] restricts which request Content-Type values an action accepts. [Produces] declares which response Content-Type values the action returns. Both affect content negotiation, routing, and OpenAPI documentation.

// [Consumes]: action only matches requests with Content-Type: application/json
[HttpPost("upload-json")]
[Consumes("application/json")]
public IActionResult ImportJson([FromBody] ImportDto dto)
    => Ok($"Imported {dto.Count} records");

// Separate action for form/multipart upload:
[HttpPost("upload-form")]
[Consumes("multipart/form-data")]
public IActionResult ImportFile(IFormFile file)
{
    // Handles: Content-Type: multipart/form-data
    using var stream = file.OpenReadStream();
    _importer.Import(stream);
    return Ok();
}
// Both routes are POST /upload-* — [Consumes] disambiguates at routing time

// [Produces]: declares response content type and drives OpenAPI output schema
[HttpGet("{id:int}")]
[Produces("application/json")]
[ProducesResponseType<Product>(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public ActionResult<Product> Get(int id) { ... }

// [Produces] on the controller: applies to all actions
[ApiController]
[Produces("application/json")]
[Route("api/[controller]")]
public class ProductsController : ControllerBase { ... }

// Support multiple response types:
[Produces("application/json", "application/xml")]
public IActionResult GetAll() => Ok(_products);

Rule of thumb: Use [Consumes] when the same route needs to accept different body formats (JSON vs form). Use [Produces] to lock down the response format and keep OpenAPI schemas accurate. Together they form the explicit contract for your endpoint.

More ways to practice

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

or
Join our WhatsApp Channel