Web API Controllers

This section documents ASP.NET Core on .NET 10 (LTS), the current release — the minimal hosting model, the middleware pipeline, dependency injection, Minimal APIs, MVC & Razor Pages, Blazor with the current render modes, SignalR and gRPC, EF Core, ASP.NET Core Identity and policy-based authorization, output caching, rate limiting, and Native-AOT-aware building — as described by the official documentation at Microsoft Learn, which is the reference these pages are written and verified against.

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. .NET ships a major release every November and its APIs continue to evolve: the examples here target .NET 10 / C# 14.

This section’s bibliography lists the reference material consulted while preparing these pages.

Controller-based Web APIs give you conventions, filters, and the [ApiController] behaviors on top of routing and model binding. Choose them over Minimal APIs for large APIs that benefit from shared base classes, action filters, or an existing MVC codebase.

ControllerBase + [ApiController]

[ApiController]
[Route("api/[controller]")]
public sealed class OrdersController(IOrderService orders) : ControllerBase
{
    [HttpGet("{id:int}")]
    public async Task<ActionResult<OrderDto>> Get(int id)
    {
        var order = await orders.FindAsync(id);
        return order is null ? NotFound() : Ok(order.ToDto());
    }

    [HttpPost]
    public async Task<ActionResult<OrderDto>> Create(CreateOrderDto dto)
    {
        var created = await orders.CreateAsync(dto);
        return CreatedAtAction(nameof(Get), new { id = created.Id }, created.ToDto());
    }
}

[ApiController] enables automatic 400 responses for invalid ModelState, binding-source inference (complex types from the body, simple types from the route/query), and ProblemDetails for error status codes. See Create web APIs with ASP.NET Core.

Action return types

Return type Use

ActionResult<T>

Return either T (200) or a result helper (NotFound(), BadRequest()).

IActionResult

When only status/results matter, no typed body.

Results<Ok<T>, NotFound, …​>

Typed union; documents every response for OpenAPI (same as Minimal APIs).

T / IEnumerable<T>

Plain object, serialized with 200.

Status helpers: Ok, Created / CreatedAtAction, NoContent, NotFound, BadRequest, Conflict, UnprocessableEntity, Problem, ValidationProblem. See Controller action return types.

Content negotiation and System.Text.Json options

The framework picks an output formatter from the Accept header (JSON by default). Configure System.Text.Json:

builder.Services.AddControllers(o => o.ReturnHttpNotAcceptable = true)  // 406 instead of ignoring Accept
    .AddJsonOptions(o =>
    {
        o.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
        o.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter());
        o.JsonSerializerOptions.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull;
    })
    .AddXmlSerializerFormatters();

AddJsonOptions configures the MVC formatters. (ConfigureHttpJsonOptions is the equivalent for Minimal APIs and does not affect controllers.)

Use [Produces("application/json")] / [Consumes("application/json")] to constrain a controller, and the System.Text.Json source generator (JsonSerializerContext) for trimming/AOT and speed. Write a custom TextOutputFormatter for formats like CSV. See Format response data.

ProblemDetails error responses

Return RFC 9457 ProblemDetails / ValidationProblemDetails for machine-readable errors:

builder.Services.AddProblemDetails(o => o.CustomizeProblemDetails = ctx =>
    ctx.ProblemDetails.Extensions["traceId"] = ctx.HttpContext.TraceIdentifier);

// in an action
return Problem(title: "Payment declined", statusCode: StatusCodes.Status402PaymentRequired);

Map exceptions to problem responses centrally with IExceptionHandler — see Error Handling, Logging, and Observability and Handle errors in ASP.NET Core web APIs.

DTOs and API contracts

Expose request/response DTOs, not EF Core entities: it decouples the wire contract from the schema, prevents over-posting, and keeps navigation properties out of responses.

public sealed record OrderDto(int Id, string Customer, decimal Total, string Status);
public static class OrderMappings
{
    public static OrderDto ToDto(this Order o) => new(o.Id, o.Customer.Name, o.Total, o.Status.ToString());
}

See Data Access with EF Core for the entities these DTOs are mapped from.

CORS

builder.Services.AddCors(o => o.AddPolicy("spa", p =>
    p.WithOrigins("https://app.example.com").AllowAnyHeader().AllowAnyMethod()));
app.UseCors("spa");

JsonPatch

Add the Microsoft.AspNetCore.JsonPatch.SystemTextJson package (.NET 10, System.Text.Json based), accept a JsonPatchDocument<T> body and call patch.ApplyTo(model); the legacy path is the Microsoft.AspNetCore.Mvc.NewtonsoftJson package with AddControllers().AddNewtonsoftJson(). See JsonPatch in ASP.NET Core web API.

Minimal APIs vs. controllers

Choose When

Minimal APIs

New, small-to-medium JSON APIs; least ceremony; fastest startup and best Native-AOT support.

Controllers

Large APIs that benefit from shared base-class logic, [ApiController] conventions, action filters (see Filters and the MVC Pipeline), or that already exist as an MVC codebase.

Both share the same routing, model binding, and hosting — the choice is about ergonomics for the app’s size and team, not capability. .http files in the project let you send requests straight from the editor for manual testing. See Choose between controller-based APIs and Minimal APIs.

Content negotiation

flowchart LR REQ["Request
Accept: application/xml"] --> NEG{"Negotiate:
match Accept to a formatter"} NEG -- "xml formatter registered" --> XML["XmlSerializerOutputFormatter"] NEG -- "no match, ReturnHttpNotAcceptable" --> S406["406 Not Acceptable"] NEG -- "no match, default" --> JSON["SystemTextJsonOutputFormatter"] XML --> BODY["Serialized response body"] JSON --> BODY