ASP.NET Core Controllers: A Practical Guide to Building REST Endpoints
If you are building a Web API in .NET, controllers are almost certainly at the center of it. An ASP.NET Core controller is a class that groups related HTTP endpoints -- the entry points into your application's request handling logic. Controllers receive HTTP requests, delegate to business logic, and return structured HTTP responses. Getting them right means clean, testable, maintainable code. Getting them wrong means a tightly coupled mess that grows harder to work with every sprint.
This guide covers everything you need to build controllers effectively in .NET 10. The difference between ControllerBase and Controller. What [ApiController] buys you automatically. How action methods and return types work. Model binding. Constructor injection. Keeping controllers thin. And the improvements .NET 10 brings to the table. These are the building blocks of every production Web API -- understanding them thoroughly pays off every time you add a new endpoint.
ControllerBase vs Controller: Know Which One You Need
Two base classes are available: ControllerBase and Controller. The choice is simple, but getting it wrong creates noise. Controller inherits from ControllerBase and adds view support -- ViewData, ViewBag, TempData, and the View() helper family. If your controller returns JSON, that extra machinery is pure overhead.
For pure Web APIs, ControllerBase is usually the better fit. It provides the complete set of HTTP response helper methods (Ok(), NotFound(), BadRequest(), Created(), NoContent(), Accepted()) without pulling in the MVC view engine.
using Microsoft.AspNetCore.Mvc;
[ApiController]
[Route("api/[controller]")]
public class CustomersController : ControllerBase
{
[HttpGet]
public IActionResult GetAll()
{
return Ok(new[] { new { Id = 1, Name = "Acme Corp" } });
}
[HttpGet("{id:int}")]
public IActionResult GetById(int id)
{
return Ok(new { Id = id, Name = "Acme Corp" });
}
[HttpPost]
public IActionResult Create([FromBody] CreateCustomerRequest request)
{
var newId = 42;
return CreatedAtAction(nameof(GetById), new { id = newId }, new { Id = newId });
}
}
public record CreateCustomerRequest(string Name, string Email);
This is the foundational pattern that every ASP.NET Core controller starts from: [ApiController] on the class, [Route("api/[controller]")] for the base URL, and ControllerBase as the parent. This is a common, pragmatic structure for production Web API controllers -- treat it as a strong starting point rather than a hard requirement.
The [ApiController] Attribute: What You Get for Free
[ApiController] is not just a label -- it activates a set of behaviors that collectively make your API more predictable and reduce boilerplate. Understanding exactly what it does is essential because it silently changes how your controllers behave.
First, it enables automatic model validation. Before your action method body begins to execute, ASP.NET Core validates the incoming request model against your data annotations. If ModelState.IsValid is false, the framework short-circuits the request and returns a 400 Bad Request with a ProblemDetails body describing each validation error. You never write if (!ModelState.IsValid) return BadRequest(ModelState) -- the framework can handle it.
Second, it enables binding source inference. Complex types in action parameters default to [FromBody]. Simple types that match a route template token default to [FromRoute]. Simple types not in the template default to [FromQuery]. This inference removes most of the explicit [From...] annotations that cluttered pre-[ApiController] code, making action signatures cleaner.
Third, it produces ProblemDetails-formatted error responses for 400 and 500 status codes by default, following the RFC 9457 standard. Consumers get a consistent error structure with type, title, status, detail, and instance fields -- no more guessing whether the error response is a plain string, a custom JSON object, or an HTML error page from Kestrel.
You can apply [ApiController] at the assembly level in Program.cs to cover all controllers in the application, or directly on each controller class. For new projects, assembly-level application is more convenient and ensures consistent behavior everywhere.
Action Methods: The Heart of the Controller
Action methods are the public methods in your controller that handle HTTP requests. They must be public, non-static, and not decorated with [NonAction] to be recognized as endpoints by the routing system. They can be synchronous or asynchronous -- in modern .NET, async is the default for any I/O-bound operation.
The return type can be IActionResult, ActionResult<T>, Task<IActionResult>, or Task<ActionResult<T>>. Using ActionResult<T> is preferred when you have a specific success return type. ActionResult<T> helps tooling infer response types, reducing how much explicit metadata you need to add in many common cases -- though non-trivial APIs often still benefit from explicit response type declarations. Swashbuckle (third-party, not part of the framework) and .NET 10's built-in Microsoft.AspNetCore.OpenApi (with built-in OpenAPI support first arriving in .NET 9) can both use this type information when generating response schemas.
[ApiController]
[Route("api/[controller]")]
public class InvoicesController : ControllerBase
{
private readonly IInvoiceService _invoiceService;
private readonly ILogger<InvoicesController> _logger;
public InvoicesController(
IInvoiceService invoiceService,
ILogger<InvoicesController> logger)
{
_invoiceService = invoiceService;
_logger = logger;
}
[HttpGet]
public async Task<ActionResult<IReadOnlyList<InvoiceSummary>>> GetAll(
CancellationToken cancellationToken)
{
var invoices = await _invoiceService.GetAllAsync(cancellationToken);
return Ok(invoices);
}
[HttpGet("{id:guid}")]
public async Task<ActionResult<InvoiceDetail>> GetById(
Guid id,
CancellationToken cancellationToken)
{
var invoice = await _invoiceService.GetByIdAsync(id, cancellationToken);
if (invoice is null)
{
return NotFound();
}
return Ok(invoice);
}
[HttpPost]
public async Task<ActionResult<InvoiceDetail>> Create(
[FromBody] CreateInvoiceRequest request,
CancellationToken cancellationToken)
{
var created = await _invoiceService.CreateAsync(request, cancellationToken);
return CreatedAtAction(nameof(GetById), new { id = created.Id }, created);
}
[HttpDelete("{id:guid}")]
public async Task<IActionResult> Delete(
Guid id,
CancellationToken cancellationToken)
{
await _invoiceService.DeleteAsync(id, cancellationToken);
return NoContent();
}
}
Notice how thin this controller is. It receives the request, delegates to _invoiceService, and returns the appropriate status code. No business logic lives here. This is the goal. Controllers that are thin are easy to test, easy to read, and easy to change.
Pass CancellationToken through to your service and repository calls wherever possible. ASP.NET Core provides a cancellation token that is triggered when the client disconnects. Propagating it prevents orphaned database queries and HTTP calls from running when nobody is waiting for the result.
Action Results: Returning the Right HTTP Response
Returning the correct HTTP status code is a core responsibility of the controller layer. Getting it wrong confuses clients and makes your API harder to consume reliably. The mapping is straightforward once you know it.
Ok(value) returns 200 with the value serialized as JSON. Use it for successful GET responses and for PUT/PATCH when you return the updated resource. Created(uri, value) returns 201 with a Location header. CreatedAtAction is the idiomatic version that generates the URL from a named action. NoContent() returns 204 -- use it for successful DELETE and for PUT/PATCH when there is nothing to return. BadRequest() returns 400. NotFound() returns 404. Conflict() returns 409 -- use it when a resource already exists.
[ApiController]
[Route("api/[controller]")]
public class MembershipsController : ControllerBase
{
[HttpPost]
public async Task<IActionResult> Register([FromBody] RegisterRequest request)
{
if (await EmailExistsAsync(request.Email))
{
return Conflict(new ProblemDetails
{
Title = "Email already registered",
Detail = $"The email address {request.Email} is already associated with an account.",
Status = StatusCodes.Status409Conflict
});
}
if (!IsAllowedDomain(request.Email))
{
return BadRequest(new ProblemDetails
{
Title = "Invalid email domain",
Detail = "Registration is restricted to company email addresses.",
Status = StatusCodes.Status400BadRequest
});
}
var member = await CreateMemberAsync(request);
return CreatedAtAction(nameof(GetById), new { id = member.Id }, member);
}
[HttpGet("{id:guid}")]
public IActionResult GetById(Guid id)
=> Ok(new { Id = id });
private Task<bool> EmailExistsAsync(string email)
=> Task.FromResult(false);
private bool IsAllowedDomain(string email)
=> email.EndsWith("@company.com", StringComparison.OrdinalIgnoreCase);
private Task<dynamic> CreateMemberAsync(RegisterRequest request)
=> Task.FromResult<dynamic>(new { Id = Guid.NewGuid() });
}
public record RegisterRequest(string Email, string DisplayName);
Using ProblemDetails in your BadRequest and Conflict responses is strongly recommended. It gives clients a consistent structure to parse regardless of which error code was returned. [ApiController] already formats automatic validation errors as ProblemDetails, so using the same format for business-rule errors ensures consistency throughout your entire API surface.
Model Binding: How Request Data Reaches Your Actions
Model binding is the process of populating action method parameters from incoming request data. ASP.NET Core supports several binding sources, each represented by a binding source attribute. Understanding which source is used for which parameter is essential for writing correct API controllers.
[FromRoute] binds from URL route values extracted by the router. [FromQuery] binds from the query string. [FromBody] binds from the request body -- for JSON payloads, the System.Text.Json serializer handles deserialization. [FromHeader] binds from HTTP request headers. [FromForm] binds from form data, used for file uploads and HTML form submissions.
With [ApiController], binding source inference handles most common cases. But explicit annotations are worth using when parameter names do not match the source keys, when you need header values as parameters, or when you want to make the binding intent crystal clear to future readers.
[ApiController]
[Route("api/exports")]
public class ExportsController : ControllerBase
{
[HttpGet("{format}")]
public IActionResult Download(
[FromRoute] string format,
[FromQuery] DateTimeOffset from,
[FromQuery] DateTimeOffset to,
[FromQuery] string? timezone = "UTC",
[FromHeader(Name = "X-Correlation-Id")] string? correlationId = null)
{
return Ok(new
{
Format = format,
From = from,
To = to,
Timezone = timezone,
CorrelationId = correlationId
});
}
[HttpPost("async")]
public IActionResult ScheduleExport(
[FromBody] ScheduleExportRequest request,
[FromHeader(Name = "X-Priority")] int priority = 5)
{
return Accepted(new { JobId = Guid.NewGuid(), Priority = priority });
}
}
public record ScheduleExportRequest(
string Format,
DateTimeOffset From,
DateTimeOffset To);
Complex types used as action parameters with [FromBody] are deserialized from the JSON request body using System.Text.Json by default. You can configure serialization options globally in builder.Services.AddControllers(options => ...).AddJsonOptions(...), or switch to Newtonsoft.Json with .AddNewtonsoftJson() if you need features like custom converters for complex inheritance hierarchies.
Dependency Injection in Controllers
Constructor injection is the standard mechanism for providing dependencies to controllers. ASP.NET Core's DI container resolves all constructor parameters automatically when the controller is activated per request. You declare private readonly fields, accept the dependencies as constructor parameters, and assign them in the constructor body. That is all there is to it.
[ApiController]
[Route("api/[controller]")]
public class NotificationsController : ControllerBase
{
private readonly INotificationService _notificationService;
private readonly IUserRepository _userRepository;
private readonly ILogger<NotificationsController> _logger;
public NotificationsController(
INotificationService notificationService,
IUserRepository userRepository,
ILogger<NotificationsController> logger)
{
_notificationService = notificationService;
_userRepository = userRepository;
_logger = logger;
}
[HttpPost("send")]
public async Task<IActionResult> Send(
[FromBody] SendNotificationRequest request,
CancellationToken cancellationToken)
{
var user = await _userRepository.GetByIdAsync(request.UserId, cancellationToken);
if (user is null)
{
return NotFound(new ProblemDetails
{
Title = "User not found",
Detail = $"No user with ID {request.UserId} exists.",
Status = StatusCodes.Status404NotFound
});
}
_logger.LogInformation(
"Sending {Channel} notification to user {UserId}",
request.Channel,
request.UserId);
await _notificationService.SendAsync(user, request, cancellationToken);
return Accepted();
}
}
public record SendNotificationRequest(Guid UserId, string Channel, string Message);
The ILogger<T> interface comes from ASP.NET Core's built-in logging infrastructure and is automatically available without explicit registration. For production applications you almost certainly want a structured logging provider wired in. How to Set Up Serilog in ASP.NET Core walks through exactly where in the pipeline to configure structured request logging with Serilog, and Logging in .NET: The Complete Developer's Guide covers the broader logging infrastructure and configuration patterns.
If you are curious about what actually happens inside the DI container when it resolves a controller's constructor parameters, How DI Containers Use Reflection Internally gives you a detailed look at the reflection and expression tree mechanics under the hood.
Keeping Controllers Thin
The single most important principle for ASP.NET Core controller design is this: controllers must not contain business logic. An ASP.NET Core controller is a coordination layer -- its job is to receive a request, delegate to a service, and map the result to an HTTP response.
When you find yourself writing loops, conditionals, or data transformations inside an action method, that logic belongs in a service. Controllers that are thin are trivial to test: mock one or two service calls, call the action, and assert the status code and response shape.
The Mediator design pattern is a popular approach here. Instead of injecting multiple specific services, you inject a single IMediator and dispatch typed command and query objects through it. Controller constructors become trivially simple, and each handler class is independently testable.
The Facade design pattern is another strong option. A facade simplifies a complex subsystem into a clean interface -- hiding multiple repositories, external API calls, and caching layers behind a single method call the controller can invoke without knowing the underlying complexity.
Filters: Cross-Cutting Concerns at the Action Level
Filters run before or after specific stages of the request pipeline at the controller and action level. They are a useful abstraction for concerns that apply to a subset of your endpoints -- too narrow for middleware, too repetitive to copy into every action.
Common uses include audit logging for write operations, response caching, custom authorization schemes, and exception-to-status-code mapping. ASP.NET Core provides IActionFilter / IAsyncActionFilter, IResultFilter, IExceptionFilter, and IResourceFilter, each with well-defined execution points. Apply them via attributes, [ServiceFilter(typeof(MyFilter))] for DI-resolved filters, or globally in builder.Services.AddControllers(options => options.Filters.Add<MyFilter>()).
Areas: Organizing Large APIs
When your API grows to dozens or hundreds of controllers, flat organization in a single Controllers/ folder becomes unwieldy. Areas let you partition controllers into named subsections with their own route namespaces and folder structures.
A controller inside an area is decorated with [Area("AreaName")]. Its route typically includes the area name: [Route("api/[area]/[controller]")]. This is common in multi-tenant or multi-module APIs where api/admin/products and api/store/products serve different audiences with different authorization policies and data access rules.
For architecturally structured applications, areas pair naturally with the modular monolith pattern. Each module owns its area, its controllers, its route namespace, and its internal service registrations. Modules cannot accidentally collide on routes or pollute each other's DI registrations, which is exactly the kind of boundary enforcement you want in a codebase that multiple teams contribute to.
.NET 10 Improvements for Controllers
.NET 10 brings several quality-of-life improvements. The Microsoft.AspNetCore.OpenApi package (with built-in OpenAPI support first arriving in .NET 9 and expanded in .NET 10) generates OpenAPI documents from controller metadata without Swashbuckle in many scenarios. The generic [ProducesResponseType<T>] attribute replaces [ProducesResponseType(typeof(MyType), 200)] with the more refactoring-friendly [ProducesResponseType<MyType>(200)].
Endpoint metadata is richer. Controllers can attach arbitrary metadata to actions, which endpoint filters and middleware can inspect via the endpoint's Metadata collection. This enables automatic response caching, rate limiting policies, and feature flag checks driven by metadata rather than scattered attributes.
Native AOT support is substantially improved -- the .NET 10 source generator handles more reflection-based patterns, reducing manual [JsonSerializable] annotations. Compile-time log source generation via [LoggerMessage] also integrates cleanly with ILogger<T>, eliminating boxing of value type parameters in hot logging paths.
Frequently Asked Questions
What is the difference between ControllerBase and Controller?
ControllerBase provides the core HTTP infrastructure: access to HttpContext, Request, Response, ModelState, RouteData, and action result helpers like Ok(), NotFound(), BadRequest(), and CreatedAtAction(). It has no view-related features.
Controller inherits from ControllerBase and adds Razor view support: View(), PartialView(), ViewData, ViewBag, and TempData. For Web API projects that return JSON, the view machinery is overhead with no benefit. Use Controller for MVC HTML-rendering applications. Use ControllerBase for Web APIs.
What does [ApiController] actually do?
[ApiController] activates three automatic behaviors. First, automatic model validation: if ModelState is invalid, the framework returns a 400 ProblemDetails response before your action runs. Second, binding source inference: complex parameters default to [FromBody], route-matched simple parameters default to [FromRoute], the rest to [FromQuery]. Third, ProblemDetails formatting for 400 and 500 responses.
It also enforces attribute routing for controllers that use it, which is the correct default for Web APIs. Collectively, these changes make API controllers far less error-prone than raw ControllerBase usage.
Should I return IActionResult or ActionResult?
Prefer ActionResult<T> for any action with a well-defined primary success type. It communicates the expected response type to OpenAPI tooling without [ProducesResponseType] annotations. The implicit conversion from T to ActionResult<T> means you can return the value directly or wrap it in Ok(value).
IActionResult is appropriate for actions that return significantly different types, or for DELETE actions that return 204 with no body. In practice, most GET and POST actions benefit from ActionResult<T>.
How do I inject services into an ASP.NET Core controller?
Use constructor injection exclusively. Declare private readonly fields for each dependency, accept them as constructor parameters, and assign them in the constructor body. Register services in Program.cs with the appropriate lifetime: AddScoped for request-scoped services, AddSingleton for stateless services.
Avoid property injection and the service locator anti-pattern. Constructor injection makes dependencies explicit and mockable. If a controller needs more than three or four dependencies, it is doing too much -- extract logic into a service or use the mediator pattern.
How do I test ASP.NET Core controllers effectively?
The most comprehensive approach is WebApplicationFactory<TProgram> from Microsoft.AspNetCore.Mvc.Testing. It boots your entire application in memory and provides an HttpClient for real HTTP requests through the full middleware, routing, model binding, and serialization pipeline. This catches integration bugs that isolated unit tests miss: routing misconfigurations and middleware ordering problems.
For unit testing individual action methods, construct the controller directly, inject mock services via NSubstitute or Moq, and call the action method. Fast and appropriate for testing branching logic. Run both approaches in CI.
How do I handle exceptions in controller actions?
Do not use try-catch blocks in action methods for predictable failures. Throw domain-specific exception types from your service layer and handle them in a global IExceptionHandler (introduced in .NET 8) registered with builder.Services.AddExceptionHandler<GlobalExceptionHandler>(). The handler maps each exception type to an appropriate HTTP status code and ProblemDetails body.
For expected outcomes like a missing resource or a failed business rule, consider returning a Result<T> from your service rather than throwing for flow control. Reserve exceptions for truly unexpected conditions: infrastructure failures and programming errors.
What is the recommended folder structure for controllers in a large ASP.NET Core Web API?
A flat Controllers/ folder works for small APIs with under 15-20 controllers. Beyond that, feature-based organization scales better. Group by domain concept: put the controller, request/response models, and service interfaces for a feature together in Features/Orders/ and Features/Invoices/. This colocation makes it easy to find everything related to a feature.
For APIs with distinct subsections -- admin vs public, internal vs external -- Areas provide route namespace isolation. Combined with the modular monolith architecture, you can package each area as its own assembly, enforcing hard boundaries at the compiler level.
For official framework documentation, see the ASP.NET Core controllers documentation on Microsoft Learn.

