Start Debugging

Typed results (Results<>) vs IResult vs IActionResult no ASP.NET Core 11

No ASP.NET Core 11, retorne Results<T1, TN> com TypedResults para minimal APIs e ActionResult<T> para controllers. Trate IResult puro e IActionResult puro como saídas de emergência: eles compilam para qualquer resposta, mas não descrevem nada ao OpenAPI, então você paga por isso em atributos ProducesResponseType escritos à mão.

Se o seu endpoint tem uma única resposta possível, declare esse único tipo concreto e siga em frente. Se ele tem várias, a resposta precisa no ASP.NET Core 11 é: retorne Results<TResult1, TResultN> com TypedResults a partir de uma minimal API, e ActionResult<T> a partir de um controller. Ambos te dão verificação em tempo de compilação de que o handler só retorna aquilo que declara, e ambos entregam ao gerador do OpenAPI os metadados da resposta de graça. Os dois tipos de interface, IResult puro e IActionResult puro, são saídas de emergência: eles compilam não importa o que você retorne, o que é exatamente a razão pela qual não descrevem nada ao framework e te forçam a escrever à mão [ProducesResponseType] ou .Produces para obter uma especificação precisa. Tudo abaixo tem como alvo o .NET 11 com Microsoft.NET.Sdk.Web e o C# 14; os tipos HttpResults se comportam da mesma forma desde o .NET 7, então o mesmo código roda no .NET 10 GA sem alterações.

Os três concorrentes no título do artigo mapeiam para dois mundos diferentes. IActionResult é o mundo dos controllers MVC. IResult e sua união tipada Results<> são o mundo das minimal APIs construído sobre o namespace Microsoft.AspNetCore.Http.HttpResults. A sutileza que torna essa comparação digna de ser escrita é que, a partir do .NET 7, os tipos HttpResults também funcionam em controllers, então numa action de controller você agora tem uma escolha genuína entre os tipos de resultado do MVC e os das minimal APIs. Escolher bem significa entender o que cada tipo carrega e o que não carrega.

A matriz de recursos

RecursoIActionResultActionResult<T>IResult (puro)Results<T1, TN>
Casa principalControllersControllersMinimal APIs + controllersMinimal APIs + controllers
Se autodescreve ao OpenAPINãoParcial (infere T)NãoSim
Precisa de [ProducesResponseType] / .ProducesSim, à vontadePara status codes que não sejam TSimNão
Verificação de retorno em tempo de compilaçãoNãoNãoNãoSim
Content negotiation / formattersSimSimNãoNão
Cast implícito a partir do tipo do payloadNão (interface)Sim (T para ActionResult<T>)NãoSim (cada arg da união)
Resultado testável diretamente em testes unitáriosCast necessárioCast necessárioCast necessário.Result concreto

Leia a matriz de cima para baixo e o padrão fica claro. As duas linhas de interface são “Não” em cada coluna de metadados e segurança. As duas linhas tipadas justificam sua verbosidade transformando “Não” em “Sim”. A única coluna em que as interfaces e o ActionResult<T> batem os tipos HttpResults é a content negotiation, e essa única linha é a pegadinha que ocasionalmente escolhe por você. Mais sobre isso abaixo.

Quando escolher Results<> (e TypedResults)

Recorra à união sempre que um endpoint de minimal API puder responder com mais de uma forma.

Aqui está a forma canônica de minimal API:

// .NET 11, C# 14 -- Program.cs
using Microsoft.AspNetCore.Http.HttpResults;

app.MapGet("/todos/{id}", async Task<Results<Ok<Todo>, NotFound>> (int id, TodoDb db) =>
{
    var todo = await db.Todos.FindAsync(id);
    return todo is null
        ? TypedResults.NotFound()
        : TypedResults.Ok(todo);
});

Sem .Produces, e o documento OpenAPI gerado lista um 200 com um schema Todo e um 404 sem corpo, ambos derivados do tipo de retorno. A conversão passo a passo, o teto de seis tipos e o retorno em testabilidade são cobertos em profundidade em como retornar uma união Results tipada a partir de um endpoint de minimal API; este artigo é sobre quando escolhê-la em vez das alternativas, não sobre como conectá-la.

Quando escolher ActionResult

Recorra ao ActionResult<T> quando você estiver escrevendo uma action de controller com um payload de sucesso principal e uma ou mais ramificações de erro.

// .NET 11, C# 14 -- ProductsController.cs
[HttpGet("{id}")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public ActionResult<Product> GetById(int id)
{
    var product = _db.Products.Find(id);
    return product is null ? NotFound() : product;   // implicit cast T -> ActionResult<T>
}

A razão pela qual ActionResult<T> existe e IActionResult não pode substituí-lo é uma regra do C#, não uma decisão do framework: o C# não permite operadores de cast implícito em interfaces. ActionResult<T> é um tipo genérico concreto, então ele pode definir a conversão implícita a partir de T que te permite escrever return product;. IActionResult é uma interface, então nunca poderá. Essa é toda a diferença ergonômica entre os dois.

Quando o IActionResult ou o IResult puro é de fato o correto

Nenhuma das interfaces está errada, elas são apenas restritas. Use-as deliberadamente, não por padrão.

A versão com IResult puro num controller fica assim, e note que os atributos estão de volta:

// .NET 11, C# 14 -- ProductsController.cs
[HttpGet("{id}")]
[ProducesResponseType<Product>(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public IResult GetById(int id)
{
    var product = _db.Products.Find(id);
    return product is null ? Results.NotFound() : Results.Ok(product);
}

Todo helper Results.* retorna IResult, então o compilador infere IResult para ambas as ramificações e nunca reclama, e o ApiExplorer vê uma interface que não diz nada sobre status codes. É por isso que as duas linhas [ProducesResponseType] são obrigatórias aqui e ausentes da versão com Results<>: os metadados não têm de onde mais vir.

A pegadinha que escolhe por você: content negotiation

Se a sua API precisa honrar cabeçalhos Accept e retornar XML, CSV ou qualquer formato além daquele que o resultado fixa no código, a família HttpResults está fora, e essa decisão sobrepõe tudo acima. A documentação é explícita ao dizer que os tipos HttpResultsnão aproveitam os Formatters configurados”, e detalha a consequência: “Alguns recursos como Content negotiation não estão disponíveis” e “O Content-Type produzido é decidido pela implementação de HttpResults.” TypedResults.Ok(product) vai serializar JSON independentemente do que o cliente pediu. Então uma API interna só de JSON é livre para usar Results<> num controller e desfrutar dos metadados autodescritivos, mas uma API pública com um formatter de XML registrado tem que continuar no ActionResult<T> / IActionResult para os endpoints que negociam. Isso é uma barreira de capacidade, não uma preferência, e é por isso que pertence ao topo da sua decisão e não ao fim.

A segunda função forçante é o seu modelo de hospedagem. Se o endpoint vive numa minimal API, IActionResult e ActionResult<T> nem sequer estão disponíveis para você; eles são tipos do MVC que dependem do pipeline de controllers. A escolha ali é sempre apenas entre IResult e Results<>, e Results<> vence para qualquer endpoint de múltiplas respostas. O trade-off completo entre os dois modelos de hospedagem está exposto em minimal APIs vs controllers no ASP.NET Core 11.

Por que as versões tipadas não compilam por acidente

Há um ponto de fricção que as pessoas encontram com Results<> e vale a pena nomeá-lo para que não seja lido como um bug. A inferência de tipos não vai construir a união para você. Isto não compila:

// .NET 11, C# 14 -- does NOT compile
app.MapGet("/todos/{id}", async (int id, TodoDb db) =>
{
    var todo = await db.Todos.FindAsync(id);
    return todo is null
        ? TypedResults.NotFound()   // NotFound
        : TypedResults.Ok(todo);    // Ok<Todo>
});

TypedResults.NotFound() e TypedResults.Ok(todo) são tipos concretos diferentes, então o compilador não consegue encontrar um tipo comum para o ternário e o lambda não tem um tipo de retorno inferível. A versão com IResult puro compilou apenas porque todo helper Results.* já é IResult, dando às ramificações um tipo compartilhado óbvio. Com TypedResults você paga pelos metadados mais ricos declarando o tipo de retorno você mesmo: Results<Ok<Todo>, NotFound> para um handler síncrono ou Task<Results<Ok<Todo>, NotFound>> para um assíncrono. Essa declaração não é boilerplate que você possa encurtar. É a string exata que o framework lê para construir a especificação, o que é a ideia inteira.

A mesma lógica explica por que ActionResult<IEnumerable<Product>> funciona, mas ActionResult<T> não pode envolver uma interface que você retorna diretamente: o cast implícito é definido a partir de T, e o C# proíbe casts implícitos em interfaces, então retornar uma instância de IEnumerable precisa de um wrapper Ok(...) explícito. Regra pequena, ocasionalmente surpreendente.

A recomendação, reafirmada com o quadro completo

O modelo mental a manter: um tipo de retorno de interface aceita qualquer coisa e não documenta nada, então o framework te faz reafirmar o contrato em atributos. Um tipo de retorno tipado, Results<> ou ActionResult<T>, é o contrato, então o compilador o impõe e o gerador do OpenAPI o lê. Escolha o tipado a menos que uma capacidade concreta, quase sempre content negotiation, force a interface. Para as ramificações que retornam uma falha de validação, alimentar a união com um ProblemHttpResult mantém a forma consistente com o pipeline embutido descrito em como customizar respostas de erro de validação de minimal API com IProblemDetailsService.

Relacionados

Fontes

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< Voltar