Start Debugging

ASP.NET Core 11 における型付き結果 (Results<>) vs IResult vs IActionResult

ASP.NET Core 11 では、minimal API には TypedResults を使って Results<T1, TN> を返し、コントローラーには ActionResult<T> を返します。素の IResult と素の IActionResult は非常手段として扱いましょう。どんなレスポンスでもコンパイルは通りますが、OpenAPI には何も伝えないため、手書きの ProducesResponseType 属性という代償を払うことになります。

エンドポイントが取りうるレスポンスが 1 つだけなら、その具体的な型 1 つを宣言して先に進みましょう。複数ある場合、ASP.NET Core 11 での的確な答えはこうです。minimal API からは TypedResults を使って Results<TResult1, TResultN> を返し、コントローラーからは ActionResult<T> を返します。どちらもハンドラーが宣言したものしか返さないことをコンパイル時にチェックしてくれ、どちらも OpenAPI ジェネレーターにレスポンスのメタデータを無償で渡してくれます。2 つのインターフェース型、素の IResult と素の IActionResult は非常手段です。何を返そうとコンパイルが通り、まさにそれゆえにフレームワークには何も伝えず、正確な仕様を得るために [ProducesResponseType].Produces を手書きさせられます。以下はすべて Microsoft.NET.Sdk.Web と C# 14 を使った .NET 11 が対象ですが、HttpResults 型は .NET 7 以降ずっと同じ挙動なので、同じコードが .NET 10 GA でも変更なく動きます。

このキュー内のタイトルにある 3 つの候補は、2 つの異なる世界に対応しています。IActionResult は MVC コントローラーの世界です。IResult とその型付き共用体 Results<> は、Microsoft.AspNetCore.Http.HttpResults 名前空間を土台とする minimal API の世界です。この比較を書く価値があるひねりは、.NET 7 以降 HttpResults 型がコントローラーでも動くようになった点です。そのため、コントローラーのアクションでは今や MVC の結果型と minimal API の結果型のあいだで本当の選択肢があります。うまく選ぶには、それぞれの型が何を伝え、何を伝えないのかを理解する必要があります。

機能マトリクス

機能IActionResultActionResult<T>IResult(素)Results<T1, TN>
主な住処コントローラーコントローラーMinimal API + コントローラーMinimal API + コントローラー
OpenAPI へ自己記述するいいえ部分的(T を推論)いいえはい
[ProducesResponseType] / .Produces が必要はい、多用するT 以外のステータスコードに対してはいいいえ
コンパイル時の戻り値チェックいいえいいえいいえはい
コンテンツネゴシエーション / フォーマッターはいはいいいえいいえ
ペイロード型からの暗黙的キャストいいえ(インターフェース)はい(T から ActionResult<T>いいえはい(各共用体引数)
結果を直接ユニットテストできるキャストが必要キャストが必要キャストが必要具体的な .Result

マトリクスを上から下へ読めば、パターンは明白です。2 つのインターフェースの行は、メタデータと安全性のすべての列で「いいえ」です。2 つの型付きの行は、その冗長さと引き換えに「いいえ」を「はい」に変えます。インターフェースと ActionResult<T>HttpResults 型に勝る唯一の列はコンテンツネゴシエーションであり、その 1 行こそが、ときにあなたの代わりに選択を決めてしまう落とし穴です。詳しくは後述します。

Results<>(と TypedResults)を選ぶとき

minimal API のエンドポイントが複数の形で応答しうるなら、いつでも共用体に手を伸ばしましょう。

これが 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);
});

.Produces は不要で、生成される OpenAPI ドキュメントには Todo スキーマを持つ 200 と、本文なしの 404 が並びます。どちらも戻り値の型から導出されます。ステップごとの変換、6 つの型という上限、そしてテストで得られる利点については、minimal API エンドポイントから型付き Results 共用体を返す方法で詳しく扱っています。この記事は、それをどう組み立てるかではなく、いつ他の選択肢より優先して選ぶかについてのものです。

ActionResult を選ぶとき

主要な成功ペイロードと 1 つ以上のエラー分岐を持つコントローラーのアクションを書いているときは、ActionResult<T> に手を伸ばしましょう。

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

ActionResult<T> が存在し、IActionResult がそれを置き換えられない理由は、フレームワークの決定ではなく C# のルールです。C# はインターフェースに暗黙的キャスト演算子を許しません。ActionResult<T> は具体的なジェネリック型なので、return product; と書けるようにする T からの暗黙的変換を定義できます。IActionResult はインターフェースなので、決して定義できません。それが両者のあいだにある、人間工学上の差のすべてです。

素の IActionResult または IResult が実際に正しいとき

どちらのインターフェースも間違いではなく、単に用途が狭いだけです。既定ではなく、意図的に使いましょう。

コントローラーでの素の IResult 版はこうなり、属性が戻ってきている点に注目してください。

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

すべての Results.* ヘルパーは IResult を返すので、コンパイラは両方の分岐について IResult を推論して一切文句を言わず、ApiExplorer はステータスコードについて何も語らないインターフェースを見ます。だからこそ 2 つの [ProducesResponseType] 行はここでは必須であり、Results<> 版では不要なのです。メタデータには他にどこからも来る場所がないからです。

あなたの代わりに選択を決めてしまう落とし穴: コンテンツネゴシエーション

もし API が Accept ヘッダーを尊重し、結果がハードコードする形式以外の XML、CSV、あるいは任意の形式を返さなければならないなら、HttpResults ファミリーは対象外であり、その決定は上記のすべてを覆します。ドキュメントは、HttpResults 型が「構成済みの Formatters を利用しない」と明言し、その帰結を綴っています。「Content negotiation のような一部の機能は利用できません」そして「生成される Content-TypeHttpResults の実装によって決められます」。TypedResults.Ok(product) は、クライアントが何を求めたかにかかわらず JSON をシリアライズします。したがって、内部の JSON 専用 API はコントローラーで Results<> を自由に使い、自己記述のメタデータを享受できますが、XML フォーマッターを登録した公開 API は、ネゴシエーションするエンドポイントについては ActionResult<T> / IActionResult にとどまらなければなりません。これは好みではなく能力の壁であり、だからこそ意思決定の末尾ではなく先頭に属するのです。

第 2 の強制要因はホスティングモデルです。エンドポイントが minimal API に存在するなら、IActionResultActionResult<T> はそもそも使えません。それらはコントローラーのパイプラインに依存する MVC 型です。そこでの選択は常に IResultResults<> のあいだだけであり、複数レスポンスのエンドポイントなら Results<> が勝ちます。2 つのホスティングモデルのあいだのトレードオフの全体は、ASP.NET Core 11 における minimal API vs コントローラーで解説しています。

なぜ型付き版が偶然にはコンパイルされないのか

Results<> で人がぶつかる摩擦が 1 つあり、バグと読まれないように名前を付けておく価値があります。型推論は共用体をあなたの代わりに組み立ててくれません。これはコンパイルされません。

// .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()TypedResults.Ok(todo) は異なる具体的な型なので、コンパイラは三項演算子の共通の型を見つけられず、ラムダには推論できる戻り値の型がありません。素の IResult 版がコンパイルできたのは、すべての Results.* ヘルパーがすでに IResult であり、分岐に明白な共有の型を与えていたからにすぎません。TypedResults では、より豊かなメタデータの代償として、戻り値の型を自分で宣言します。同期ハンドラーには Results<Ok<Todo>, NotFound>、非同期ハンドラーには Task<Results<Ok<Todo>, NotFound>> です。その宣言は短縮できる定型文ではありません。それはフレームワークが仕様を組み立てるために読み取る、まさにその文字列であり、それこそが眼目です。

同じ論理が、ActionResult<IEnumerable<Product>> は機能するのに、ActionResult<T> が直接返すインターフェースをラップできない理由を説明します。暗黙的キャストは T から定義され、C# はインターフェースへの暗黙的キャストを禁じるため、IEnumerable のインスタンスを返すには明示的な Ok(...) のラッパーが必要です。小さなルールですが、ときに驚かされます。

全体像とともに再述する推奨

保つべきメンタルモデルはこうです。インターフェースの戻り値の型は何でも受け入れ、何も文書化しないので、フレームワークは契約を属性で再表明させます。型付きの戻り値の型、Results<> または ActionResult<T> は契約そのものなので、コンパイラがそれを強制し、OpenAPI ジェネレーターがそれを読み取ります。具体的な能力、ほぼ常にコンテンツネゴシエーションがインターフェースを強いない限り、型付きの方を選びましょう。バリデーション失敗を返す分岐については、ProblemHttpResult を共用体に組み込むことで、IProblemDetailsService を使って minimal API のバリデーションエラーレスポンスをカスタマイズする方法で説明した組み込みのパイプラインと形を一貫させられます。

関連記事

参考文献

Comments

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

< 戻る