ActionResult<T> and Return Types
Choose between T, IActionResult, ActionResult<T> and IResult, return the right status codes, and describe them with [ProducesResponseType].
public ActionResult<Order> Get(int id) =>
store.Find(id) is { } order ? order : NotFound();
return CreatedAtAction(nameof(Get), new { id = order.Id }, order);
return Problem(statusCode: 409, title: "Already paid");An action can return a plain value, an IActionResult, an ActionResult<T> or a minimal API IResult. All four work; they differ in what the compiler checks and what OpenAPI can see.
| Return type | Can return | Response type known to OpenAPI |
|---|---|---|
Order | Only the value (200) | Yes |
IActionResult | Any result | No, every status needs [ProducesResponseType] with a type |
ActionResult<Order> | The value or any result | Yes for 200 |
IResult | TypedResults, as in minimal APIs | Describe it with [ProducesResponseType] |
Default to ActionResult<T>: return order; and return NotFound(); both compile through implicit conversions, and the success type is documented for free.
The helper methods
ControllerBase has a helper per status: Ok(value), CreatedAtAction(actionName, routeValues, value) (201 plus a Location built by link generation), CreatedAtRoute, AcceptedAtAction (202), NoContent(), NotFound(), Conflict(), BadRequest(), Problem(...), ValidationProblem(ModelState) and File(bytes, contentType, name). With [ApiController] a bodiless error such as NotFound() is rewritten into problem details; NotFound(someObject) sends your object unchanged.
Content negotiation and its surprises
A value or ObjectResult goes through output formatters, which honour the Accept header. Two defaults catch teams out:
- Returning
nullgives 204 No Content, not 404. A lookup that returnsOrder?straight from the repository tells clients the order exists and is empty. - Returning a
stringgivestext/plain, not a JSON string. Wrap it in an object.
An IResult bypasses output formatters and content negotiation: TypedResults.Ok(value) always writes JSON whatever the Accept header says, while text, file and bytes results write their own content type. Returning IAsyncEnumerable<T> streams the array as items arrive instead of buffering it.
Two runtime traps
The Async suffix. MVC strips Async from action names (SuppressAsyncSuffixInActionNames is true), so CreatedAtAction(nameof(GetOrderAsync), ...) looks for an action that does not exist and throws InvalidOperationException: No route matches the supplied values. The order was already saved, the client sees a 500 and retries, and you get a duplicate. Use CreatedAtRoute with a route name, or name actions without the suffix.
Interfaces. C# has no user-defined conversion from an interface, so an action declared as ActionResult<IEnumerable<Order>> cannot return orders; when orders is typed as IEnumerable<Order> (error CS0029). Return a concrete type such as orders.ToList(), or wrap it in Ok(orders).
Describing responses
Add [ProducesResponseType] for every status a client must handle: [ProducesResponseType<Order>(StatusCodes.Status201Created)], [ProducesResponseType(StatusCodes.Status404NotFound)]. The built-in OpenAPI generator reads these attributes, so an undocumented 409 never reaches generated clients.
Example
When to use it
- Return 201 Created with a Location header for POST /api/invoices so clients can fetch the new invoice without guessing its URL.
- Answer a second POST /api/invoices/{number}/pay with a 409 problem details body instead of silently succeeding twice.
- Stop GET /api/orders/{id} from returning 204 for missing orders by switching from Order? to ActionResult<Order>.
More examples
The Async suffix trap and its fix
Route names are unique strings that link generation resolves directly, so <code>CreatedAtRoute</code> is immune to the action-name rewrite. The alternatives are naming actions without the suffix or setting <code>SuppressAsyncSuffixInActionNames = false</code> in <code>AddControllers</code>, which changes every action name in the app at once. The failure is nasty because it happens after the side effect: the payment is captured and the client only sees a 500.
using Microsoft.AspNetCore.Mvc;
public record Payment(Guid Id, string OrderId, decimal Amount, string Currency);
public record CapturePayment(string OrderId, decimal Amount, string Currency);
public interface IPaymentService
{
Task<Payment?> FindAsync(Guid id, CancellationToken ct);
Task<Payment> CaptureAsync(CapturePayment request, CancellationToken ct);
}
[ApiController]
[Route("api/payments")]
public sealed class PaymentsController(IPaymentService payments) : ControllerBase
{
// MVC registers this action as "GetPayment": the Async suffix is stripped.
[HttpGet("{id:guid}", Name = "GetPayment")]
public async Task<ActionResult<Payment>> GetPaymentAsync(Guid id, CancellationToken ct) =>
await payments.FindAsync(id, ct) is { } payment ? payment : NotFound();
[HttpPost]
public async Task<ActionResult<Payment>> CapturePaymentAsync(CapturePayment request, CancellationToken ct)
{
var payment = await payments.CaptureAsync(request, ct);
// Wrong: CreatedAtAction(nameof(GetPaymentAsync), ...) throws
// "No route matches the supplied values" after the payment was captured.
return CreatedAtRoute("GetPayment", new { id = payment.Id }, payment);
}
}
Documenting every status for OpenAPI
<code>MapOpenApi()</code> serves <code>/openapi/v1.json</code>, built from these attributes and the action signatures. <code>ActionResult<ProductDto></code> already documents the 200 type, but listing it keeps all statuses in one place. <code>Reserve</code> returns <code>IActionResult</code>, so without the attributes the document would show a bare 200 and a generated client would have no idea that 409 exists.
using Microsoft.AspNetCore.Mvc;
// Program.cs: builder.Services.AddOpenApi(); ... app.MapOpenApi();
public record ProductDto(string Sku, string Name, decimal Price, int InStock);
public record ReserveStock(int Quantity);
[ApiController]
[Route("api/products")]
[Produces("application/json")]
public sealed class ProductsController : ControllerBase
{
[HttpGet("{sku}")]
[EndpointSummary("Get a product by SKU")]
[ProducesResponseType<ProductDto>(StatusCodes.Status200OK)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound, "application/problem+json")]
public ActionResult<ProductDto> Get(string sku) =>
sku == "KETTLE-BLUE" ? new ProductDto(sku, "Blue kettle", 39.95m, 12) : NotFound();
[HttpPost("{sku}/reservations")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound, "application/problem+json")]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status409Conflict, "application/problem+json")]
public IActionResult Reserve(string sku, ReserveStock request)
{
if (sku != "KETTLE-BLUE") return NotFound();
if (request.Quantity > 12)
return Problem(statusCode: StatusCodes.Status409Conflict, title: "Not enough stock");
return NoContent();
}
}
Unit testing an ActionResult<T>
An <code>ActionResult<T></code> holds either <code>Value</code> (the implicit conversion from <code>T</code>) or <code>Result</code> (an action result), never both, so the tests assert on the branch they expect. This tests the decision logic only: the problem details body for the 404 is added later by MVC, which an integration test with <code>WebApplicationFactory</code> covers.
using Microsoft.AspNetCore.Mvc;
using NSubstitute;
using Xunit;
public record Shipment(string Id, string OrderId, string Carrier, string Status);
public interface IShipmentStore
{
Task<Shipment?> FindAsync(string id, CancellationToken ct);
}
[ApiController]
[Route("api/shipments")]
public sealed class ShipmentsController(IShipmentStore store) : ControllerBase
{
[HttpGet("{id}")]
public async Task<ActionResult<Shipment>> Get(string id, CancellationToken ct) =>
await store.FindAsync(id, ct) is { } shipment ? shipment : NotFound();
}
public sealed class ShipmentsControllerTests
{
private readonly IShipmentStore _store = Substitute.For<IShipmentStore>();
[Fact]
public async Task Found_shipment_is_returned_as_the_value()
{
_store.FindAsync("SHP-77", Arg.Any<CancellationToken>())
.Returns(new Shipment("SHP-77", "ORD-1001", "DHL", "InTransit"));
var controller = new ShipmentsController(_store);
var result = await controller.Get("SHP-77", TestContext.Current.CancellationToken);
Assert.Equal("DHL", result.Value?.Carrier);
Assert.Null(result.Result);
}
[Fact]
public async Task Missing_shipment_is_a_404()
{
var controller = new ShipmentsController(_store);
var result = await controller.Get("SHP-404", TestContext.Current.CancellationToken);
Assert.IsType<NotFoundResult>(result.Result);
Assert.Null(result.Value);
}
}
Discussion