Start Debugging

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

Geben Sie in ASP.NET Core 11 Results<T1, TN> mit TypedResults für Minimal APIs und ActionResult<T> für Controllers zurück. Behandeln Sie das nackte IResult und das nackte IActionResult als Notausgänge: Sie kompilieren für jede Antwort, beschreiben OpenAPI aber nichts, sodass Sie in handgeschriebenen ProducesResponseType-Attributen dafür bezahlen.

Wenn Ihr Endpunkt genau eine mögliche Antwort hat, deklarieren Sie diesen einen konkreten Typ und gut ist. Hat er mehrere, lautet die klare Antwort in ASP.NET Core 11: Geben Sie aus einer Minimal API Results<TResult1, TResultN> mit TypedResults zurück und aus einem Controller ActionResult<T>. Beide geben Ihnen eine Prüfung zur Kompilierzeit, dass der Handler nur zurückgibt, was er deklariert, und beide liefern dem OpenAPI-Generator die Antwort-Metadaten kostenlos. Die zwei Interface-Typen, das nackte IResult und das nackte IActionResult, sind Notausgänge: Sie kompilieren, egal was Sie zurückgeben, was genau der Grund ist, warum sie dem Framework nichts beschreiben und Sie zwingen, [ProducesResponseType] oder .Produces von Hand zu schreiben, um eine korrekte Spezifikation zu bekommen. Alles Folgende zielt auf .NET 11 mit Microsoft.NET.Sdk.Web und C# 14 ab; die HttpResults-Typen verhalten sich seit .NET 7 gleich, sodass derselbe Code unverändert auf .NET 10 GA läuft.

Die drei Kandidaten im Titel dieser Reihe bilden zwei verschiedene Welten ab. IActionResult ist die Welt der MVC-Controller. IResult und seine typisierte Union Results<> sind die Minimal-API-Welt, aufgebaut auf dem Namespace Microsoft.AspNetCore.Http.HttpResults. Der Kniff, der diesen Vergleich lohnenswert macht, ist, dass die HttpResults-Typen seit .NET 7 auch in Controllers funktionieren, sodass Sie bei einer Controller-Action jetzt eine echte Wahl zwischen den MVC-Ergebnistypen und den Minimal-API-Typen haben. Gut zu wählen bedeutet zu verstehen, was jeder Typ trägt und was nicht.

Die Feature-Matrix

FunktionIActionResultActionResult<T>IResult (nackt)Results<T1, TN>
Primäre HeimatControllersControllersMinimal APIs + ControllersMinimal APIs + Controllers
Beschreibt sich selbst gegenüber OpenAPINeinTeilweise (leitet T ab)NeinJa
Braucht [ProducesResponseType] / .ProducesJa, reichlichFür Statuscodes außerhalb von TJaNein
Rückgabeprüfung zur KompilierzeitNeinNeinNeinJa
Content Negotiation / FormatterJaJaNeinNein
Implizite Umwandlung aus dem Payload-TypNein (Interface)Ja (T zu ActionResult<T>)NeinJa (jedes Union-Argument)
Direkt unit-testbares ErgebnisCast nötigCast nötigCast nötigKonkretes .Result

Lesen Sie die Matrix von oben nach unten und das Muster ist klar. Die beiden Interface-Zeilen sind bei jeder Metadaten- und Sicherheitsspalte “Nein”. Die beiden typisierten Zeilen rechtfertigen ihre Ausführlichkeit, indem sie “Nein” in “Ja” verwandeln. Die eine Spalte, in der die Interfaces und ActionResult<T> die HttpResults-Typen schlagen, ist Content Negotiation, und genau diese Zeile ist der Haken, der gelegentlich die Entscheidung für Sie trifft. Mehr dazu unten.

Wann Sie Results<> (und TypedResults) wählen sollten

Greifen Sie zur Union, sobald ein Minimal-API-Endpunkt mit mehr als einer Form antworten kann.

Hier ist die kanonische Minimal-API-Form:

// .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);
});

Kein .Produces, und das generierte OpenAPI-Dokument listet einen 200 mit einem Todo-Schema und einen 404 ohne Body auf, beide aus dem Rückgabetyp abgeleitet. Die schrittweise Umstellung, das Sechs-Typen-Limit und der Testgewinn werden ausführlich in Wie man eine typisierte Results-Union aus einem Minimal-API-Endpunkt zurückgibt behandelt; in diesem Beitrag geht es darum, wann man sie gegenüber den Alternativen wählt, nicht wie man sie verdrahtet.

Wann Sie ActionResult wählen sollten

Greifen Sie zu ActionResult<T>, wenn Sie eine Controller-Action mit einem primären Erfolgs-Payload und einem oder mehreren Fehlerzweigen schreiben.

// .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>
}

Der Grund, warum ActionResult<T> existiert und IActionResult es nicht ersetzen kann, ist eine C#-Regel, keine Framework-Entscheidung: C# erlaubt keine impliziten Umwandlungsoperatoren auf Interfaces. ActionResult<T> ist ein konkreter generischer Typ, sodass es die implizite Umwandlung aus T definieren kann, die Ihnen erlaubt, return product; zu schreiben. IActionResult ist ein Interface, kann es also nie. Das ist die gesamte ergonomische Lücke zwischen den beiden.

Wann das nackte IActionResult oder IResult tatsächlich richtig ist

Kein Interface ist falsch, sie sind nur eng. Nutzen Sie sie bewusst, nicht als Standard.

Die nackte IResult-Variante in einem Controller sieht so aus, und beachten Sie, dass die Attribute wieder da sind:

// .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);
}

Jeder Results.*-Helper gibt IResult zurück, sodass der Compiler IResult für beide Zweige ableitet und sich nie beschwert, und der ApiExplorer sieht ein Interface, das nichts über Statuscodes aussagt. Deshalb sind die zwei [ProducesResponseType]-Zeilen hier zwingend und in der Results<>-Variante abwesend: Die Metadaten haben keinen anderen Ursprung.

Der Haken, der die Entscheidung für Sie trifft: Content Negotiation

Wenn Ihre API Accept-Header respektieren und XML, CSV oder ein anderes Format als das vom Ergebnis fest codierte zurückgeben muss, ist die HttpResults-Familie außen vor, und diese Entscheidung überschreibt alles Vorherige. Die Dokumentation ist eindeutig, dass die HttpResults-Typen die “not leverage the configured Formatters,” und legt die Konsequenz dar: “Some features like Content negotiation aren’t available” und “The produced Content-Type is decided by the HttpResults implementation.” TypedResults.Ok(product) serialisiert JSON, egal was der Client verlangt hat. Eine interne, ausschließlich JSON-basierte API darf also Results<> in einem Controller nutzen und die selbstbeschreibenden Metadaten genießen, aber eine öffentliche API mit einem registrierten XML-Formatter muss für die Endpunkte, die verhandeln, bei ActionResult<T> / IActionResult bleiben. Das ist eine Fähigkeitsgrenze, keine Vorliebe, weshalb sie an den Anfang Ihrer Entscheidung gehört und nicht ans Ende.

Die zweite erzwingende Funktion ist Ihr Hosting-Modell. Wenn der Endpunkt in einer Minimal API lebt, sind IActionResult und ActionResult<T> gar nicht verfügbar; es sind MVC-Typen, die von der Controller-Pipeline abhängen. Die Wahl dort besteht immer nur zwischen IResult und Results<>, und Results<> gewinnt für jeden Endpunkt mit mehreren Antworten. Der vollständige Kompromiss zwischen den beiden Hosting-Modellen wird in Minimal APIs vs Controllers in ASP.NET Core 11 dargelegt.

Warum die typisierten Varianten nicht zufällig kompilieren

Es gibt einen Reibungspunkt, auf den Leute bei Results<> stoßen, und es lohnt sich, ihn zu benennen, damit er nicht wie ein Bug wirkt. Die Typinferenz baut die Union nicht für Sie auf. Das kompiliert nicht:

// .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() und TypedResults.Ok(todo) sind verschiedene konkrete Typen, sodass der Compiler keinen gemeinsamen Typ für den ternären Ausdruck finden kann und das Lambda keinen ableitbaren Rückgabetyp hat. Die nackte IResult-Variante kompilierte nur, weil jeder Results.*-Helper bereits IResult ist, was den Zweigen einen offensichtlichen gemeinsamen Typ gibt. Mit TypedResults bezahlen Sie für die reichhaltigeren Metadaten, indem Sie den Rückgabetyp selbst deklarieren: Results<Ok<Todo>, NotFound> für einen synchronen Handler oder Task<Results<Ok<Todo>, NotFound>> für einen asynchronen. Diese Deklaration ist kein Boilerplate, das Sie kürzen können. Sie ist genau die Zeichenfolge, die das Framework liest, um die Spezifikation zu bauen, was der ganze Sinn ist.

Dieselbe Logik erklärt, warum ActionResult<IEnumerable<Product>> funktioniert, aber ActionResult<T> kein Interface umschließen kann, das Sie direkt zurückgeben: Die implizite Umwandlung ist aus T definiert, und C# verbietet implizite Umwandlungen auf Interfaces, sodass die Rückgabe einer IEnumerable-Instanz einen expliziten Ok(...)-Wrapper braucht. Kleine Regel, gelegentlich überraschend.

Die Empfehlung, mit dem vollen Bild neu formuliert

Das mentale Modell, das Sie behalten sollten: Ein Interface-Rückgabetyp akzeptiert alles und dokumentiert nichts, sodass das Framework Sie zwingt, den Vertrag in Attributen erneut aufzustellen. Ein typisierter Rückgabetyp, Results<> oder ActionResult<T>, ist der Vertrag, sodass der Compiler ihn erzwingt und der OpenAPI-Generator ihn liest. Wählen Sie den typisierten, es sei denn, eine konkrete Fähigkeit, fast immer Content Negotiation, erzwingt das Interface. Für die Zweige, die einen Validierungsfehler zurückgeben, hält das Einspeisen eines ProblemHttpResult in die Union die Form konsistent mit der eingebauten Pipeline, die in Wie man die Fehlerantworten der Minimal-API-Validierung mit IProblemDetailsService anpasst beschrieben wird.

Verwandt

Quellen

Comments

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

< Zurück