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() から戻ってくると「下から上へ」戻ります。今回の実行順を、実際の出力と結び付けて整理すると次のとおりです。
| 実行ステップ | どこが動くか | 出力されやすい文字列例 | なぜそうなるか |
|---|---|---|---|
| 1 | Middleware 1(before) | Middleware 1 (before) | 最初の Use の next 前 |
| 2 | Middleware 2(before) | Middleware 2 (before) | 2番目の Use の next 前 |
| 3 | Terminal middleware 1 | Terminal middleware 1! | Run は終端なのでここで下方向の進行が止まる |
| 4 | Middleware 2(after) | Middleware 2 (after) | next が完了したので「戻り側」が実行される |
| 5 | Middleware 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 API | UseAuthentication → 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)のミドルウェアは強力ですが、強力なぶん「終端」を置く場所を間違えると挙動が一気に変わります。パイプラインの“行き”と“戻り”を意識すると、今回のようなトラブルは最短で解消できます。

コメント