ASP.NET Core(.NET 9)でミドルウェアの文字列は返るのにAPIレスポンスが返らない原因と解決策(Run/Use/MapGet)

ASP.NET Core(.NET 9)のミドルウェアを試していると、Response.WriteAsync() で文字列は返るのに、MapGet などの API(エンドポイント)が返すはずのレスポンスが表示されないことがあります。原因は「登録順」と「app.Run(…) の性質」です。仕組みを押さえて、目的に合う直し方まで具体例で整理します。

目次

起きている症状:ミドルウェアの出力だけ返り、MapGet の返却値が出ない

たとえば次のように、app.Use(...) でカスタムミドルウェアを2つ登録し、さらに app.Run(...) で終端(ターミナル)ミドルウェアを登録して Response.WriteAsync() で文字列を書き込む構成を考えます。

期待としては「ミドルウェアのログっぽい文字列」+「最終的に app.MapGet("/", () => "Hello World!") が返す Hello World!」の両方がレスポンスに入ってほしい、という状態です。しかし実際には、レスポンスに出るのはミドルウェア側の文字列だけで、肝心の Hello World! が出てきません。

再現しやすいコード例(Minimal API / .NET 9)

(説明のため、出力内容を分かりやすい文字列にしています)

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

app.Use(async (context, next) =>
{
await context.Response.WriteAsync("Middleware 1 (before)\n");
await next();
await context.Response.WriteAsync("Middleware 1 (after)\n");
});

app.Use(async (context, next) =>
{
await context.Response.WriteAsync("Middleware 2 (before)\n");
await next();
await context.Response.WriteAsync("Middleware 2 (after)\n");
});

// ここが「終端ミドルウェア」
app.Run(async context =>
{
await context.Response.WriteAsync("Terminal middleware 1!\n");
});

// 2つ目の終端(通常は実行されない)
app.Run(async context =>
{
await context.Response.WriteAsync("Terminal middleware 2!\n");
});

app.MapGet("/", () => "Hello World!");

// これはアプリ起動(サーバー開始)の Run
app.Run();

この構成だと、ブラウザや curl で / にアクセスしても、Hello World! が返ってきません。代わりに「Middleware 1/2 の before・after と Terminal middleware 1!」のような出力だけが返る、という現象になります。

結論:原因は app.Run(…) が「終端ミドルウェア」だから

原因はシンプルで、app.Run(RequestDelegate) は終端(ターミナル)ミドルウェアだからです。終端ミドルウェアはパイプラインの“ここで処理を終える”場所になり、後続の処理へ進みません。

  • app.Use(...):await next() を呼べる(=後続処理へ進める)。前後に処理を書ける。
  • app.Run(...):next を受け取らない(=後続処理へ進めない)。ここでパイプラインが終了する。
  • app.MapGet / app.MapPost / コントローラー:エンドポイント。ルーティングで一致したときに実行される“最終的な処理”。

つまり、app.Run(async ctx => ...) を置いた地点でリクエスト処理が完結してしまい、MapGet で登録したエンドポイントまで到達しません。

今の登録順を「概念」で見るとこうなる

質問の状況をパイプラインの流れとして表すと、概ね次のような並びです(概念図)。

登録順(上から下へ)種類次へ進める?結果
Use(ミドルウェア1)ミドルウェア進める(nextあり)前後で WriteAsync 可能
Use(ミドルウェア2)ミドルウェア進める(nextあり)前後で WriteAsync 可能
Run(ターミナル1)終端ミドルウェア進めないここが“終点”になる
Run(ターミナル2)終端ミドルウェア進めない1つ目の Run に到達した時点で終了するため、通常ここには来ない
MapGet(“/”)エンドポイント―Run(ターミナル1) が先に処理を終えるため、実行されない

この表のとおり、Run(ターミナル1) が“終点”です。ここに入った瞬間、エンドポイントへは進みません。結果として「ミドルウェアの文字列は返るのに API レスポンスが返らない」状態が発生します。

なぜ「Use の after 側」が実行されて出力が増えるのか

「Run で止まるなら、Use の後半(after 側)がなぜ動くの?」という点も混乱しやすいポイントです。ここは ミドルウェアが“前半(next 前)”と“後半(next 後)”を持つことを理解するとスッキリします。

Use の典型形は次のとおりです。

app.Use(async (context, next) =>
{
    // 前半:次へ進む前
    await context.Response.WriteAsync("before\n");


await next(); // ここで次のミドルウェアへ

// 後半:次から戻ってきた後
await context.Response.WriteAsync("after\n");


});

リクエストは「上から下へ」進み、await next() から戻ってくると「下から上へ」戻ります。今回の実行順を、実際の出力と結び付けて整理すると次のとおりです。

実行ステップどこが動くか出力されやすい文字列例なぜそうなるか
1Middleware 1(before)Middleware 1 (before)最初の Use の next 前
2Middleware 2(before)Middleware 2 (before)2番目の Use の next 前
3Terminal middleware 1Terminal middleware 1!Run は終端なのでここで下方向の進行が止まる
4Middleware 2(after)Middleware 2 (after)next が完了したので「戻り側」が実行される
5Middleware 1(after)Middleware 1 (after)さらに戻って最初の Use の after が実行される

ポイントは、Run が「後続へ進めない」だけであって、Use の after 側まで“戻ってくる”ことはできるという点です。だからこそ、質問のように「before → before → terminal → after → after」という順で文字列が積み上がります。

なぜコンパイルエラーにならないのか:登録と実行は別

「Run が終端なら、後ろに MapGet を書いた時点で警告やエラーになってほしい」と感じるかもしれません。しかし、app.MapGet は“このルートに対してこう処理する”という エンドポイントの登録であり、実行順の保証とは別のレイヤーです。

ASP.NET Core では、起動時にあなたが書いた順に「ミドルウェアのチェーン(パイプライン)」を組み立て、リクエストごとにそのチェーンを上から順に実行します。Run のような終端が途中にあると、登録はできても実行時に到達できないという状態が成立します。

実行時に初めて分かるパターン(条件分岐やパス分岐など)もあるため、フレームワーク側が一律に「この MapGet は到達不能」と断定できないことも、エラーになりにくい理由です。

混同しやすい:ミドルウェアの Run と、アプリ起動の Run は別物

ASP.NET Core では Run という名前が2つの意味で使われるため、ここも混乱の元になります。

書き方意味役割注意点
app.Run(async ctx => ...)終端ミドルウェアを追加パイプラインをその場で終了させる後ろに MapGet/MapControllers を置いても到達しない
app.Run() / await app.RunAsync()ホストを起動して待機Web サーバーとして動かすこれはパイプライン登録ではなく、アプリ実行開始

今回問題になるのは前者の app.Run(RequestDelegate) です。後者の app.Run() は「アプリを起動するために最後に書くもの」で、問題の本質とは別です。

修正方法:目的別に最適解が変わる

「Hello World(エンドポイントのレスポンス)を返したいのか」「終端処理を残したいのか」で直し方が変わります。ここでは現場で選びやすいように、目的別に3パターンを紹介します。

エンドポイント(MapGet/コントローラー)まで到達させたい

最も素直な解決策は、終端の Run をやめて Use に置き換えることです。つまり、ミドルウェアとして次へ進めるようにします。

app.Use(async (context, next) =>
{
    // 何かしらの共通処理(ログ、計測、ヘッダー追加など)
    // ※API では Response に文字列を書き込むより、ログが安全
    await next(); // ここで MapGet などのエンドポイントへ進む
});

もし「特定条件のときだけ終端にしたい」なら、次のように条件分岐で next() を呼び分けます。

app.Use(async (context, next) =>
{
    if (context.Request.Path.StartsWithSegments("/healthz"))
    {
        context.Response.StatusCode = StatusCodes.Status200OK;
        await context.Response.WriteAsync("OK");
        return; // 終端(ここで終了)
    }

    await next(); // それ以外は通常どおり次へ
});

この形なら / へアクセスしたときは MapGet が動き、/healthz だけ独自応答、といった構成にできます。

終端処理を残したい(フォールバックや独自 404 など)

「エンドポイントが処理しなかったときだけ最後に何か返したい」というケースでは、終端処理を“最後”に置くのが基本です。つまり MapGet や MapControllers の後ろに配置します。

app.MapGet("/", () => "Hello World!");
app.MapGet("/api/ping", () => Results.Ok(new { ok = true }));

// どこにもマッチしなかった場合の最終処理
app.Run(async context =>
{
    context.Response.StatusCode = StatusCodes.Status404NotFound;
    await context.Response.WriteAsync("Not Found");
});

app.Run();

ただし、API 開発では app.Run よりも app.MapFallback を使う方が意図が明確で、ルーティングと一体で管理しやすいです。

app.MapGet("/", () => "Hello World!");

app.MapFallback(async context =>
{
    context.Response.StatusCode = StatusCodes.Status404NotFound;
    await context.Response.WriteAsync("Not Found");
});

app.Run();

「フォールバック」は “終端ミドルウェアでパイプラインを強制終了する” というより、エンドポイントとして 404 を定義するイメージです。Minimal API(.NET 9)との相性が良いので、迷ったらこちらが無難です。

app.Run(…) を2つ書いているが、2つ目が動かない

これは今回の核心でもありますが、1つ目の Run が終端なので、2つ目には到達しません。終端が複数必要に見えるときは、大抵「条件分岐」か「分岐パイプライン」が適切です。

パターン1:if/else で1つの Run にまとめる

app.Run(async context =>
{
    if (context.Request.Path.StartsWithSegments("/internal"))
    {
        await context.Response.WriteAsync("Internal terminal");
        return;
    }


await context.Response.WriteAsync("Public terminal");


});

パターン2:MapWhen / UseWhen でパイプラインを分ける

URL やヘッダー条件で “別ルート” にしたい場合は、分岐用 API を使うと読みやすくなります。

app.MapWhen(
    context => context.Request.Path.StartsWithSegments("/admin"),
    adminApp =>
    {
        adminApp.Run(async ctx => await ctx.Response.WriteAsync("Admin terminal"));
    });

app.MapGet("/", () => "Hello World!");
app.Run();

この場合、/admin のときだけ分岐先の終端が動き、それ以外は通常どおり MapGet が処理します。

コントローラー(MVC)を使う場合の「基本の並び」も押さえる

Minimal API だけでなく、コントローラー(MapControllers)を使うプロジェクトでも考え方は同じです。終端は最後、認証・認可などは通常エンドポイントの前、という原則を守ると事故が減ります。

目的よくある並び(例)ポイント
認証・認可のある Web APIUseAuthentication → UseAuthorization → MapControllers / MapGet認可より前に終端を置くと、認証が動かない
共通ログ・計測Use(next 前後で計測) → エンドポイントレスポンス本文ではなくログへ出す方が安全
独自 404(最後に返す)エンドポイント → MapFallback または最後の Run前に置くと“何でも 404”になりやすい

「ミドルウェアを足したら突然 API が全部返らない」場合、認証・例外処理・フォールバックなど、どれかが終端になっているケースが多いので、登録順を一度表に書き出すのが効果的です。

実はもう1つ大事:API で Response.WriteAsync を多用すると“壊れやすい”

今回の「Hello World が出ない」原因は Run による終端ですが、同時に Response.WriteAsync() でレスポンス本文をミドルウェアから直接書く設計は、API だと事故りやすいことも知っておくと安心です。

  • JSON を返す API で、途中で文字列を混ぜると JSON が壊れます(パースできない)。
  • エンドポイントが Content-Type や StatusCode を決める前に body を書くと、ヘッダーが確定してしまうことがあります。
  • ボディへ書き込み済みだと、後続で例外が起きたときに正しいエラーレスポンスへ差し替えできない場合があります。

API の実装で「レスポンスを見える形でデバッグしたい」ときは、次のように Response.HasStarted をログに出すだけでも判断材料になります。

app.Use(async (context, next) =>
{
    // リクエスト開始時点
    var startedBefore = context.Response.HasStarted;

    await next();

    // エンドポイント実行後
    var startedAfter = context.Response.HasStarted;
    // startedAfter が true なら、すでにヘッダー/ボディが送信開始されている可能性が高い
});

また「やりたいこと」別に、本文を書かずに実現できる手段を選ぶと API が壊れにくくなります。

やりたいことおすすめ理由
リクエスト/レスポンスのログを取りたいILogger でログ出力レスポンス本文を壊さずに観測できる
処理時間を測りたいStopwatch + next 前後で計測API の結果に影響しない
共通ヘッダーを付けたいResponse.Headers に追加本文ではなくヘッダーなら API 形式を保てる
エラー時の統一フォーマットExceptionHandler / ProblemDetails / フィルター例外・ステータスと整合が取りやすい

どうしても本文を触りたい場合は、レスポンスのストリームを差し替えてバッファリングするなど、より高度な手法が必要になります。ただしコストが高く、性能やメモリにも影響するので、まずは「ログやヘッダー」で目的が達成できないかを検討するのが現実的です。

最短で直すためのチェックリスト

「ミドルウェアの文字列は返るのに、API が返らない」系のトラブルは、原因が似通っていることが多いです。次のチェックを上から順に見ると、切り分けが速くなります。

  • app.Run(async ...) を MapGet/MapControllers より前に置いていないか(置いていたらそれが終端になっている可能性が高い)
  • app.Use(...) の中で await next() を呼び忘れていないか(呼び忘れも事実上の終端になる)
  • MapGet のルートが本当にマッチしているか(ベースパス、ルートパラメータ、末尾スラッシュなど)
  • API で body に文字列を書き込んでいないか(JSON などが壊れて “返っているけど読めない” ケースもある)
  • 例外が握りつぶされていないか(開発環境ではログレベルを上げて確認する)

とくに await next() の呼び忘れは、Run と同じくらい頻出です。Use で「ログだけ取りたい」つもりが、うっかり次へ進めずにエンドポイントが一切動かない…という事故が起こります。

まとめ:Run は「コントローラーの後に呼ばれる」ではなく、「そこで終わる」

今回の現象は、app.Run(RequestDelegate) が終端ミドルウェアであり、そこでパイプラインが終了することが原因です。Run を置いた地点より後ろにあるエンドポイント(MapGet やコントローラー)は、原理的に実行されません。

  • エンドポイントへ到達させたいなら:Run を Use に置き換え、await next() を呼ぶ
  • 終端(フォールバック)を残したいなら:エンドポイント定義の後ろに置く(または MapFallback を使う)
  • 終端を複数にしたいなら:条件分岐でまとめる、または MapWhen/UseWhen で分岐

ASP.NET Core(.NET 9)のミドルウェアは強力ですが、強力なぶん「終端」を置く場所を間違えると挙動が一気に変わります。パイプラインの“行き”と“戻り”を意識すると、今回のようなトラブルは最短で解消できます。

この記事を書いた人

実務の現場で詰まりがちなポイントを地図にするITブログ「IT trip」を運営。Windows/Office(Teams・Excel)からSQL、サーバ運用、ガジェットまで、再現性のある手順と“なぜそうなるか”を丁寧に解説します。読んだらすぐ試せること、そして迷った人の次の一歩が見えることを大切にしています。

コメント

コメントする

目次