API Controllers

Build an attribute-routed API controller with AddControllers, MapControllers and [ApiController], and know what each piece switches on.

Syntaxbuilder.Services.AddControllers(); app.MapControllers(); [ApiController] [Route("api/orders")] public class OrdersController : ControllerBase { [HttpGet("{id:int}")] public ActionResult<Order> Get(int id) { } }

A controller is a class whose public methods (actions) handle HTTP requests. In ASP.NET Core 10 controllers sit on the same endpoint routing as minimal APIs: builder.Services.AddControllers() registers the MVC services an API needs (model binding, validation, JSON formatters, filters) without Razor views, and app.MapControllers() turns every attribute-routed action into an endpoint. AddControllersWithViews() and AddMvc() add view support an HTTP API never uses.

How controllers are found and created

At startup MVC scans its application parts (the entry assembly plus referenced assemblies that depend on MVC) for controller types: public, non-abstract, non-generic classes whose name ends in Controller, that derive from such a class, or carry [Controller]. Every public method becomes an action unless it has [NonAction]. Derive from ControllerBase, not Controller: the latter adds view helpers.

Controllers are not registered in the DI container. For every request the framework creates a new instance and resolves its constructor parameters from the request scope, so injecting a scoped DbContext is safe and instance fields never survive between requests. AddControllersAsServices() registers them in DI, which is rarely needed.

What [ApiController] switches on

BehaviourEffect
Attribute routing requiredAn action reachable only through a conventional route fails at startup.
Automatic 400Invalid model state returns a ValidationProblemDetails body before your action runs.
Binding source inferenceComplex types bind from the body, route-named parameters from the route, registered services from DI, the rest from the query string.
Problem details for errorsNotFound(), Conflict() and other bodiless 4xx/5xx results get an RFC 9457 body with a traceId.

Put it on every API controller, or once on a shared base class (it is inherited) or the assembly.

Attribute routing

[Route("api/orders")] on the class is a prefix; [HttpGet("{id:int}")] on an action appends to it. A template starting with / ignores the prefix. Matching is case-insensitive. The [controller] token is replaced with the class name minus the suffix, so links generated by CreatedAtAction contain /api/Orders/42 unless you set RouteOptions.LowercaseUrls. Teams usually write literal routes instead: renaming a class should never change a public URL. Route names (Name = "GetOrder") must be unique across the whole app.

What goes wrong in production

  • A public helper method without an HTTP attribute inherits the class route and answers every verb on it; two such methods make GET /api/orders fail with an ambiguous match (500). Keep helpers private or mark them [NonAction].
  • A controller in a class library that MVC does not scan never registers and its routes return 404 with no error. Add the library with AddApplicationPart(typeof(InvoicesController).Assembly).
  • Forgetting MapControllers() gives the same silent 404 for every action.

Example

Try it yourself
Loading editor…
Press Run to execute the code.

When to use it

  • Expose GET /api/orders/{id} and POST /api/orders from one controller that injects a scoped repository per request.
  • Move the billing controllers into a shared class library and register it with AddApplicationPart so its routes keep working.
  • Give every controller [ApiController] and JSON-only output by deriving from one abstract ApiControllerBase.

More examples

A base class that carries the API conventions

Putting <code>[ApiController]</code> on an abstract base class means a new controller cannot forget it, and shared helpers live in one place. <code>Problem(...)</code> writes an RFC 9457 body with your own title and detail, which is more useful to a client than the generic body that a bare <code>NotFound()</code> gets. The read-only dictionary is static and immutable, so sharing it between requests is safe; a mutable static collection on a controller would be a race condition.

Example · csharp
using Microsoft.AspNetCore.Mvc;

// [ApiController] and [Produces] are inherited, so every derived controller gets them.
[ApiController]
[Produces("application/json")]
public abstract class ApiControllerBase : ControllerBase
{
    // Protected methods are never actions. A public helper here would need [NonAction].
    protected ObjectResult NotFoundProblem(string resource, string id) =>
        Problem(
            statusCode: StatusCodes.Status404NotFound,
            title: $"{resource} not found",
            detail: $"{resource} '{id}' does not exist.");
}

public record Customer(string Id, string Name, string Email);

[Route("api/customers")]
public sealed class CustomersController : ApiControllerBase
{
    private static readonly IReadOnlyDictionary<string, Customer> Customers = new Dictionary<string, Customer>
    {
        ["CUST-1042"] = new("CUST-1042", "Ada Lovelace", "[email protected]"),
        ["CUST-2077"] = new("CUST-2077", "Grace Hopper", "[email protected]"),
    };

    // GET /api/customers/CUST-1042
    [HttpGet("{customerId}")]
    public ActionResult<Customer> Get(string customerId) =>
        Customers.TryGetValue(customerId, out var customer)
            ? customer
            : NotFoundProblem("Customer", customerId);
}

Program.cs: lowercase links and a controller library

<code>AddApplicationPart</code> is the fix when a controller lives in a library that MVC does not discover on its own: without it the route silently returns 404. <code>LowercaseUrls</code> only changes generated links; incoming matching is already case-insensitive. Because this controller uses the <code>[controller]</code> token, renaming the class to <code>BillingInvoicesController</code> would move the public URL, which is why many teams write literal routes.

Example · csharp
using Microsoft.AspNetCore.Mvc;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddProblemDetails();

// Generated links (CreatedAtAction, LinkGenerator) use /api/invoices, not /api/Invoices.
builder.Services.Configure<RouteOptions>(options => options.LowercaseUrls = true);

builder.Services
    .AddControllers()
    // Controllers in Billing.Api.Controllers.dll are scanned explicitly.
    .AddApplicationPart(typeof(InvoicesController).Assembly);

var app = builder.Build();

app.UseExceptionHandler();
app.UseStatusCodePages();
app.UseAuthentication();
app.UseAuthorization();

app.MapControllers();

app.Run();

public record Invoice(string Number, decimal Total);

// Stand-in for a controller that normally lives in a separate class library.
[ApiController]
[Route("api/[controller]")]
public sealed class InvoicesController : ControllerBase
{
    [HttpGet("{number}")]
    public ActionResult<Invoice> Get(string number) => new Invoice(number, 129.90m);
}

An integration test that pins the contract

Routing mistakes (a missing <code>MapControllers</code>, a library that is not scanned, a renamed class that moves a <code>[controller]</code> route) all show up as 404s at runtime, never as compile errors. A <code>WebApplicationFactory</code> test that calls the real URL catches them in CI. The test asserts the problem details media type too, because clients often branch on it.

Example · csharp
using System.Net;
using System.Net.Http.Json;
using Microsoft.AspNetCore.Mvc.Testing;
using Xunit;

public sealed class OrdersControllerTests(WebApplicationFactory<Program> factory)
    : IClassFixture<WebApplicationFactory<Program>>
{
    [Fact]
    public async Task Unknown_order_returns_404_problem_details()
    {
        var client = factory.CreateClient();

        var response = await client.GetAsync("/api/orders/999999", TestContext.Current.CancellationToken);

        Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
        Assert.Equal("application/problem+json", response.Content.Headers.ContentType?.MediaType);
    }

    [Fact]
    public async Task Placing_an_order_returns_201_with_location()
    {
        var client = factory.CreateClient();

        var response = await client.PostAsJsonAsync("/api/orders",
            new { customerId = "CUST-1042", sku = "KETTLE-BLUE", quantity = 2 },
            TestContext.Current.CancellationToken);

        Assert.Equal(HttpStatusCode.Created, response.StatusCode);
        Assert.StartsWith("/api/orders/", response.Headers.Location?.AbsolutePath);
    }
}

// Stand-in so this file compiles alone. In a real test project delete it: the declaration
// belongs in the API project's Program.cs, or this class would shadow the real Program.
public partial class Program;

Discussion

  • Be the first to comment on this lesson.