On this page
Controllers and REST APIs in ASP.NET Core
Controllers in ASP.NET Core
While Minimal APIs are ideal for simple APIs, controllers offer better organization for large APIs:
- Logical grouping of endpoints in a class
- Inheritance to share behavior
- Filters at controller or action level
- Better integration with testing tools
// Minimal controller structure
[ApiController]
[Route("api/[controller]")]
public class OrdersController : ControllerBase
{
[HttpGet]
public IActionResult GetAll() => Ok(new[] { "order1", "order2" });
}[ApiController] and its behaviors
The [ApiController] attribute automatically enables:
- ModelState validation — returns 400 if data is invalid
- Source inference —
[FromBody],[FromRoute],[FromQuery]are inferred - Standard ProblemDetails error responses (RFC 7807)
// Without [ApiController] — must check manually
[HttpPost]
public IActionResult Create(ProductDto dto)
{
if (!ModelState.IsValid) return BadRequest(ModelState);
// ...
}
// With [ApiController] — automatic validation, no boilerplate
[HttpPost]
public IActionResult Create(ProductDto dto)
{
// If dto is invalid, this code never executes
// ASP.NET already returned a 400 with the errors
return Ok();
}Routing
ASP.NET Core supports various routing patterns:
// Route on the controller
[Route("api/v1/[controller]")] // [controller] → controller name without "Controller"
// Route on actions
[HttpGet] // GET /api/v1/products
[HttpGet("{id:int}")] // GET /api/v1/products/5
[HttpGet("{id:int}/detail")] // GET /api/v1/products/5/detail
[HttpGet("search/{term}")] // GET /api/v1/products/search/laptop
[HttpGet("~/admin/products")] // ~ overrides the controller route
// Route constraints
// :int, :long, :bool, :datetime, :guid, :minlength(3), :maxlength(50), :range(1,100)
[HttpGet("{id:int:min(1)}")] // only integers >= 1
[HttpGet("{slug:minlength(3)}")] // only strings with at least 3 charsModel Binding
How ASP.NET extracts data from the request:
[HttpGet("{id:int}")]
public IActionResult Get(
int id, // [FromRoute] inferred
[FromQuery] string? filter, // ?filter=value
[FromHeader(Name="X-API-Key")] string? apiKey, // header
[FromServices] ILogger<ProductsController> log // DI directly
)
[HttpPost]
public IActionResult Post(
[FromBody] CreateProductDto dto // JSON body
)
// Form data (for uploads)
[HttpPost("upload")]
public IActionResult Upload(
IFormFile file, // multipart file
[FromForm] string description // form field
)DataAnnotations for validation
public class OrderRequest
{
[Required]
[StringLength(100)]
public string CustomerName { get; set; } = string.Empty;
[EmailAddress]
public string Email { get; set; } = string.Empty;
[Phone]
public string? Phone { get; set; }
[MinLength(1, ErrorMessage = "Order must have at least one item")]
public List<OrderItemDto> Items { get; set; } = new();
[Range(typeof(DateTime), "2020-01-01", "2030-12-31")]
public DateTime DeliveryDate { get; set; }
[CreditCard]
public string? CardNumber { get; set; }
[Url]
public string? Website { get; set; }
[Compare(nameof(Email))]
public string? ConfirmEmail { get; set; }
}Custom validation
// Custom validation attribute
public class FutureDateAttribute : ValidationAttribute
{
protected override ValidationResult? IsValid(object? value, ValidationContext ctx)
{
if (value is DateTime date && date <= DateTime.Today)
return new ValidationResult("Date must be in the future");
return ValidationResult.Success;
}
}
public class StatusChangeRequest
{
[Required]
[FutureDate]
public DateTime EffectiveDate { get; set; }
[Required]
public string NewStatus { get; set; } = string.Empty;
}ActionResult\
The recommended return type for actions that return data:
// ActionResult<T> allows returning the type T or an ActionResult
[HttpGet("{id:int}")]
public async Task<ActionResult<ProductDto>> GetById(int id)
{
var product = await _service.GetAsync(id);
if (product is null)
return NotFound(new ProblemDetails
{
Title = "Product not found",
Detail = $"No product with ID {id} exists",
Status = 404,
Instance = $"/api/v1/products/{id}"
});
return Ok(product); // ActionResult wraps the DTO
}
// ControllerBase helpers available
// Ok(obj) → 200 OK with body
// Created(uri, obj) → 201 Created with Location header
// CreatedAtAction(...)→ 201 Created with generated URL
// BadRequest(obj) → 400 Bad Request
// Unauthorized() → 401 Unauthorized
// Forbid() → 403 Forbidden
// NotFound() → 404 Not Found
// NoContent() → 204 No Content
// Conflict(obj) → 409 ConflictAction filters
Filters execute code before/after actions:
// Custom filter
public class LogActionFilter : IActionFilter
{
private readonly ILogger<LogActionFilter> _logger;
private Stopwatch? _sw;
public LogActionFilter(ILogger<LogActionFilter> logger) => _logger = logger;
public void OnActionExecuting(ActionExecutingContext context)
{
_sw = Stopwatch.StartNew();
_logger.LogInformation(
"Executing {Action}", context.ActionDescriptor.DisplayName);
}
public void OnActionExecuted(ActionExecutedContext context)
{
_sw?.Stop();
_logger.LogInformation(
"Completed in {Ms}ms", _sw?.ElapsedMilliseconds);
}
}
// Register globally
builder.Services.AddControllers(opts =>
{
opts.Filters.Add<LogActionFilter>();
});Practice
- Full CRUD: Create a
CustomersControllerwith all REST endpoints forCustomer(int Id, string Name, string Email). Include DataAnnotations validation. - Pagination: Implement a
GET /api/customers?page=1&size=5endpoint that returnsPagedResult<CustomerDto>. - Custom validation: Create a
[LettersOnly]attribute that validates a string contains only letters and use it on theNamefield ofCreateCustomerRequest.
In the next lesson we will learn Entity Framework Core to persist our API data in a relational database.
CreatedAtAction for correct 201 responses
Use CreatedAtAction(nameof(GetById), new { id = product.Id }, product) instead of Created() directly. This automatically generates a Location header pointing to the URL of the new resource, which complies with the REST standard for 201 Created responses.
ActionResult<T> vs IActionResult
Use ActionResult<T> when the endpoint always returns the same data type on success. This allows Swagger to generate more accurate documentation. Use IActionResult when you can return different types depending on the case (e.g., Ok(dto) or File(stream)).
[ApiController] and automatic validation
The [ApiController] attribute enables automatic ModelState validation: if the request body does not pass DataAnnotations, ASP.NET returns a 400 Bad Request with the errors before executing your method. You do not need if (!ModelState.IsValid) in every action.
using Microsoft.AspNetCore.Mvc;
// [ApiController] enables: automatic validation, source inference, 400 responses
[ApiController]
[Route("api/v1/[controller]")] // → /api/v1/products
public class ProductsController : ControllerBase
{
private readonly IProductService _service;
private readonly ILogger<ProductsController> _logger;
// Constructor injection
public ProductsController(IProductService service,
ILogger<ProductsController> logger)
{
_service = service;
_logger = logger;
}
// GET /api/v1/products?page=1&size=10&category=electronics
[HttpGet]
[ProducesResponseType<PagedResult<ProductDto>>(StatusCodes.Status200OK)]
public async Task<ActionResult<PagedResult<ProductDto>>> GetAll(
[FromQuery] int page = 1,
[FromQuery] int size = 10,
[FromQuery] string? category = null)
{
_logger.LogInformation("Querying products. Page {Page}", page);
var result = await _service.ListAsync(page, size, category);
return Ok(result);
}
// GET /api/v1/products/5
[HttpGet("{id:int}")]
[ProducesResponseType<ProductDto>(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<ProductDto>> GetById(int id)
{
var product = await _service.GetByIdAsync(id);
return product is null ? NotFound() : Ok(product);
}
// POST /api/v1/products
[HttpPost]
[ProducesResponseType<ProductDto>(StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<ActionResult<ProductDto>> Create(
[FromBody] CreateProductRequest request)
{
var product = await _service.CreateAsync(request);
return CreatedAtAction(nameof(GetById),
new { id = product.Id }, product);
}
// PUT /api/v1/products/5
[HttpPut("{id:int}")]
public async Task<ActionResult<ProductDto>> Update(
int id, [FromBody] UpdateProductRequest request)
{
var updated = await _service.UpdateAsync(id, request);
return updated is null ? NotFound() : Ok(updated);
}
// DELETE /api/v1/products/5
[HttpDelete("{id:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
public async Task<IActionResult> Delete(int id)
{
bool deleted = await _service.DeleteAsync(id);
return deleted ? NoContent() : NotFound();
}
}
Sign in to track your progress