.NET 9 の ASP.NET Core Web API で「グローバル例外処理ミドルウェア」を自作したところ、app.UseMiddleware<GlobalExceptionHandlingMiddleware>() で起動時に RequestDelegate を解決できず落ちる――この手のトラブルは、ミドルウェアの実装パターンが混ざっているのが原因であるケースがほとんどです。この記事では原因の見分け方と、正しい実装・登録方法を実運用目線で整理します。
発生するエラー:RequestDelegate を解決できずアプリが起動しない
代表的なエラーメッセージは次のとおりです。
例外:InvalidOperationException: Unable to resolve service for type 'Microsoft.AspNetCore.Http.RequestDelegate' ...
この状態では、DI の登録(AddTransient / AddScoped / AddSingleton)を変えても根本原因が解決しないため、同じエラーを繰り返しがちです。
| 症状 | よくある勘違い | 本当の原因 | 最短の直し方 |
|---|---|---|---|
起動時に RequestDelegate が解決できない | DI 登録のライフタイムが悪い(Transient/Scoped/Singletonの選択ミス) | IMiddleware と従来型ミドルウェアの実装パターンが混在している | 「どちらの形で作るか」を決めてコードを統一する |
UseMiddleware を呼んだ瞬間に落ちる | フレームワーク側の不具合 | ミドルウェアのコンストラクタに RequestDelegate を置いたまま IMiddleware を実装している | IMiddleware なら ctor から RequestDelegate を消して InvokeAsync の next を使う |
結論:ミドルウェアの実装パターンは「2種類」あり、混ぜると壊れる
ASP.NET Core(.NET 9 の Web API を含む)でミドルウェアを自作する代表的な形は大きく次の2つです。
| パターン | クラスが実装するもの | RequestDelegate(次の処理)の受け取り方 | DI 登録 | 典型的な用途 |
|---|---|---|---|---|
| IMiddleware パターン | IMiddleware | InvokeAsync(HttpContext context, RequestDelegate next) の next 引数 | 必須(例:AddTransient) | コンストラクタで Scoped サービスを受けたい、DI 寄りにしたい |
| 従来型(Conventional)パターン | 何も実装しない(通常) | コンストラクタの RequestDelegate next をフィールドに保持 | 必須ではない(必要なら登録してOK) | 昔からの定番。シンプルで分かりやすい |
今回のエラーは、「IMiddleware を実装しているのに、従来型の書き方(コンストラクタで RequestDelegate を受け取る)をしてしまっている」ことが原因です。
なぜ RequestDelegate を DI で解決できないのか
RequestDelegate は「パイプライン上の次のミドルウェア(次の処理)を呼ぶためのデリゲート」です。これはサービスとしてコンテナに登録して解決するものではなく、フレームワークがミドルウェア呼び出しの流れの中で渡してくれるものです。
ところが、IMiddleware を実装すると、ミドルウェアのインスタンスは DI コンテナから解決されます。このときコンストラクタに RequestDelegate があると、DI は「RequestDelegate というサービスを登録から探そう」としてしまい、当然見つからず例外になります。
まず確認:今のミドルウェアが「混ざっている」サイン
次に当てはまると、混在している可能性が高いです。
- クラスに
: IMiddlewareが付いている - それなのにコンストラクタで
RequestDelegate nextを受け取っている InvokeAsyncにもRequestDelegate nextがあり、nextが二重に存在している(または使い分けが曖昧)AddTransient/AddScoped/AddSingletonを変えても改善しない
やってしまいがちな「NG例」:IMiddleware なのに ctor で RequestDelegate を受ける
次は典型的な事故パターンです(※意図的に悪い例です)。
using Microsoft.AspNetCore.Http;
public sealed class GlobalExceptionHandlingMiddleware : IMiddleware
{
private readonly RequestDelegate _next; // ← IMiddleware では基本不要
private readonly ILogger<GlobalExceptionHandlingMiddleware> _logger;
// ← ここで RequestDelegate を DI で解決しようとして落ちる
public GlobalExceptionHandlingMiddleware(RequestDelegate next, ILogger<GlobalExceptionHandlingMiddleware> logger)
{
_next = next;
_logger = logger;
}
public async Task InvokeAsync(HttpContext context, RequestDelegate next)
{
try
{
// どっちの next を呼ぶ?という別の混乱も生む
await next(context);
}
catch (Exception ex)
{
_logger.LogError(ex, "Unhandled exception");
throw;
}
}
}
この形だと、UseMiddleware<GlobalExceptionHandlingMiddleware>() で組み込んだ瞬間に RequestDelegate を解決できず起動で落ちます。
解決策:実装パターンを「どちらか」に統一する
ここからが本題です。解決策は2つあり、どちらも正解です。プロジェクトの方針(DI をどれだけ前提にするか)で選びましょう。
解決策A:IMiddleware として正しく実装する(今回の流れならこちら)
IMiddleware を使うなら、コンストラクタから RequestDelegate を消し、InvokeAsync に渡される next を呼びます。ILogger や設定値、リポジトリなどはコンストラクタ DI で問題ありません。
実装例:グローバル例外処理ミドルウェア(IMiddleware 版)
using System.Net;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
public sealed class GlobalExceptionHandlingMiddleware : IMiddleware
{
private readonly ILogger _logger;
private readonly IHostEnvironment _env;
public GlobalExceptionHandlingMiddleware(
ILogger<GlobalExceptionHandlingMiddleware> logger,
IHostEnvironment env)
{
_logger = logger;
_env = env;
}
public async Task InvokeAsync(HttpContext context, RequestDelegate next)
{
try
{
await next(context);
}
catch (Exception ex)
{
// すでにレスポンスが始まっている場合は、書き換えると更に壊れる
if (context.Response.HasStarted)
{
_logger.LogWarning(ex,
"Unhandled exception occurred after the response started. TraceId: {TraceId}",
context.TraceIdentifier);
throw;
}
await WriteProblemDetailsAsync(context, ex);
}
}
private async Task WriteProblemDetailsAsync(HttpContext context, Exception ex)
{
// ここでログを集約(要件に合わせてレベルや内容を調整)
_logger.LogError(ex,
"Unhandled exception. Path: {Path}, TraceId: {TraceId}",
context.Request.Path.Value,
context.TraceIdentifier);
context.Response.Clear();
context.Response.StatusCode = StatusCodes.Status500InternalServerError;
context.Response.ContentType = "application/problem+json; charset=utf-8";
var problem = new ProblemDetails
{
Status = StatusCodes.Status500InternalServerError,
Title = "サーバー内部でエラーが発生しました。",
Detail = _env.IsDevelopment() ? ex.ToString() : null,
Instance = context.Request.Path
};
// 追跡用の情報を extensions に入れておくと実運用で便利
problem.Extensions["traceId"] = context.TraceIdentifier;
await context.Response.WriteAsJsonAsync(problem);
}
}
DI 登録と組み込み(Program.cs)
IMiddleware 版は DI 登録が必須です。一般的には Transient を選びます(状態を持たない前提)。
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
// IMiddleware 版は登録必須
builder.Services.AddTransient<GlobalExceptionHandlingMiddleware>();
var app = builder.Build();
// なるべく早い段階で(後段の例外をまとめて拾う)
app.UseMiddleware<GlobalExceptionHandlingMiddleware>();
app.UseHttpsRedirection();
app.UseAuthorization();
app.MapControllers();
app.Run();
ポイントは、例外を拾いたい対象より前に置くことです。グローバル例外処理はパイプライン全体を包むイメージなので、基本は早めに入れます。
例外レスポンス設計:ProblemDetails を使うと API が整う
「例外を握りつぶさずに、クライアントが扱える形で返す」ために、Problem Details(RFC 7807 の形式)をベースにすると運用が安定します。最低限、次を決めると API 仕様が崩れにくくなります。
- ステータスコード(500固定にしないか、例外に応じて変えるか)
- エラーの種類(title/コード)
- 詳細情報(本番では内部情報を出さない)
- 追跡情報(traceId、相関IDなど)
| 例外の種類(例) | HTTP ステータス | クライアント向けの扱い | 返す情報の例 |
|---|---|---|---|
| バリデーション例外(入力不正) | 400 | ユーザー入力を修正して再送 | フィールド名と理由 |
| 認可/権限不足 | 403 | 権限不足(再ログイン等) | 一般的なメッセージ |
| 見つからない | 404 | リソースが存在しない | 対象IDなど(過度に出し過ぎない) |
| 想定外の例外(未ハンドル) | 500 | サーバー側の障害 | traceId と汎用メッセージ |
必要なら「例外→ステータス」のマッピングを追加して、500 以外も返すようにします。例えば DomainException や NotFoundException をプロジェクトで定義しておくと、グローバル例外処理の価値が上がります。
例外ごとにステータスを切り替える例
private async Task WriteProblemDetailsAsync(HttpContext context, Exception ex)
{
var (statusCode, title) = ex switch
{
ArgumentException => (StatusCodes.Status400BadRequest, "リクエストが不正です。"),
KeyNotFoundException => (StatusCodes.Status404NotFound, "対象が見つかりません。"),
UnauthorizedAccessException => (StatusCodes.Status403Forbidden, "権限がありません。"),
_ => (StatusCodes.Status500InternalServerError, "サーバー内部でエラーが発生しました。")
};
_logger.LogError(ex,
"Unhandled exception. Status: {Status}, Path: {Path}, TraceId: {TraceId}",
statusCode, context.Request.Path.Value, context.TraceIdentifier);
if (context.Response.HasStarted)
{
throw;
}
context.Response.Clear();
context.Response.StatusCode = statusCode;
context.Response.ContentType = "application/problem+json; charset=utf-8";
var problem = new ProblemDetails
{
Status = statusCode,
Title = title,
Detail = _env.IsDevelopment() ? ex.ToString() : null,
Instance = context.Request.Path
};
problem.Extensions["traceId"] = context.TraceIdentifier;
await context.Response.WriteAsJsonAsync(problem);
}
「どの例外を 400/404 に寄せるか」は API 設計の方針です。運用中にログが増え過ぎたり、クライアントが 500 と 400 を誤解して無限リトライする事故が起きたりするので、チームで基準を持っておくと強いです。
Response.HasStarted を必ず見る理由(実運用の事故を減らす)
例外が発生した時点で、すでにレスポンスヘッダーやボディの一部が送信済みの場合があります(ストリーミング、ファイル送信、巨大レスポンスの途中など)。この状態で StatusCode や ContentType を触ると、クライアント側で壊れた JSON が返ったり、サーバー側で別の例外が重なったりして調査が難しくなります。
そのため、グローバル例外処理ミドルウェアでは次のルールが安全です。
Response.HasStartedがtrueなら、基本は書き換えずにログして再スロー(接続はサーバーが適切に切る)- 書き換える場合は
Response.Clear()を使い、StatusCodeとContentTypeを先に設定してから JSON を書く
解決策B:従来型ミドルウェアとして実装する(IMiddleware をやめる)
「昔からの形」に統一する方法です。こちらは RequestDelegate をコンストラクタで受け取り、InvokeAsync(HttpContext context) 内でフィールドの _next を呼びます。この形なら、RequestDelegate を DI が解決する必要がありません。
実装例:従来型ミドルウェア
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
public sealed class GlobalExceptionHandlingMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger _logger;
private readonly IHostEnvironment _env;
public GlobalExceptionHandlingMiddleware(
RequestDelegate next,
ILogger<GlobalExceptionHandlingMiddleware> logger,
IHostEnvironment env)
{
_next = next;
_logger = logger;
_env = env;
}
public async Task InvokeAsync(HttpContext context)
{
try
{
await _next(context);
}
catch (Exception ex)
{
if (context.Response.HasStarted)
{
_logger.LogWarning(ex,
"Unhandled exception occurred after the response started. TraceId: {TraceId}",
context.TraceIdentifier);
throw;
}
_logger.LogError(ex,
"Unhandled exception. Path: {Path}, TraceId: {TraceId}",
context.Request.Path.Value,
context.TraceIdentifier);
context.Response.Clear();
context.Response.StatusCode = StatusCodes.Status500InternalServerError;
context.Response.ContentType = "application/problem+json; charset=utf-8";
var problem = new ProblemDetails
{
Status = StatusCodes.Status500InternalServerError,
Title = "サーバー内部でエラーが発生しました。",
Detail = _env.IsDevelopment() ? ex.ToString() : null,
Instance = context.Request.Path
};
problem.Extensions["traceId"] = context.TraceIdentifier;
await context.Response.WriteAsJsonAsync(problem);
}
}
}
組み込み(Program.cs)
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
var app = builder.Build();
app.UseMiddleware();
app.UseHttpsRedirection();
app.UseAuthorization();
app.MapControllers();
app.Run();
従来型は、ミドルウェア自体を DI 登録していなくても UseMiddleware がインスタンス生成を行えるケースが多いです(ただし、コンストラクタに追加依存を置く場合は DI に登録されたサービスとして解決できる必要があります)。
どちらを選ぶべきか:実務で迷ったときの判断基準
「直せればどっちでもよい」ではなく、運用・保守の観点で決めると後が楽です。
| 観点 | IMiddleware | 従来型 |
|---|---|---|
| コンストラクタで Scoped サービスを受けたい | 得意(DI が都度作れる前提にしやすい) | 注意が必要(起動時生成だと Scoped を捕まえてしまう) |
| シンプルさ / 既存知見 | チームにより慣れが必要 | 定番で分かりやすい |
| 登録の明示性 | 登録必須で意図が明確 | 登録不要で動くこともあるが、逆に見落としやすい場合も |
| 「パターン混在」の事故耐性 | 実装ルールを守れば安定 | 慣れているほど安定 |
今回の質問のように IMiddleware 前提で作り始めたなら、IMiddleware に統一してしまうのが最もスムーズです。逆に、既存プロジェクトが従来型で統一されているなら、そこに合わせるのが保守コストを下げます。
「登録方法を変えても直らない」理由をもう一段深掘り
AddTransient/AddScoped/AddSingleton の違いは「同じ型をいつ生成し、どこまで使い回すか」です。一方で、今回問題になっているのは「DI が解決しようとしている型がそもそもサービスではない」点です。
RequestDelegate は「ミドルウェアの次を指すデリゲート」であり、サービスコンテナが管理する依存関係の対象ではありません。だからこそ、ライフタイムをどうしても解決できず、登録方法を変えても改善しません。
実運用で役立つ改善ポイント(グローバル例外処理の品質を上げる)
ContentType は早めに設定する
クライアントが JSON としてパースし、エラー処理を一貫させるためにも、例外時の返却形式は明確にします。ProblemDetails を採用するなら次が無難です。
application/problem+json; charset=utf-8
ログには最低限「Path」「TraceId」「Status」を残す
グローバル例外処理は「ログの入口」になりやすい場所です。過不足なく調査できるよう、最低限次を含めると後で助かります。
- リクエストパス(
context.Request.Path) - 追跡ID(
context.TraceIdentifier) - 決定したステータスコード
返すメッセージは「運用中に変えられる」構造にする
例外レスポンスに内部情報を出し過ぎるとセキュリティ的に危険ですし、出さなさ過ぎるとクライアントのデバッグができません。よくある落としどころは次の運用です。
- 本番:タイトルは汎用、詳細は出さない、traceId を出す
- 開発:詳細(スタックトレース)を出す、またはログで見えるようにする
ミドルウェアの順序で「捕まえられる例外」と「捕まえられない例外」が変わる
例えば、グローバル例外処理を MapControllers() より前に入れるのは基本です。一方で、認証・認可ミドルウェアや、他の例外処理(UseExceptionHandler 等)と併用する場合は「どこで握るか」を明確にしないと、ログの二重出力やレスポンス上書きが起きます。
| 状況 | 起きやすい問題 | 対策 |
|---|---|---|
| 例外処理が複数ある(独自 + 組み込み) | 同じ例外が二重ログ / 返却形式が混在 | どちらを最終責任にするか決め、片方に寄せる |
| レスポンス開始後の例外 | 壊れた JSON、二次例外 | Response.HasStarted を見て再スロー |
| 例外→400/404のマッピングが曖昧 | クライアントが 500 と誤認しリトライ地獄 | 例外型を定義し、マッピング表をコードで固定する |
最終チェックリスト:この問題を確実に潰す
| チェック項目 | OK の状態 | NG の典型 |
|---|---|---|
| 実装パターンが統一されている | IMiddleware なら ctor に RequestDelegate がない | IMiddleware + ctor に RequestDelegate |
| IMiddleware 版の登録 | AddTransient<GlobalExceptionHandlingMiddleware>() | 登録していない / 別の型を登録している |
| 次の処理の呼び出し | IMiddleware 版は await next(context) | _next と next が混在 |
| 例外時のレスポンス制御 | HasStarted を確認してから書き換える | 無条件に StatusCode/WriteAsJsonAsync を実行 |
まとめ:RequestDelegate のエラーは DI ではなく「書き方の混在」を疑う
.NET 9 の Web API でグローバル例外処理ミドルウェアを自作したとき、Unable to resolve service for type 'RequestDelegate' が出る場合、DI のライフタイム変更では解決しないことが多いです。原因は「IMiddleware パターン」と「従来型ミドルウェアパターン」の混在であり、どちらかに統一すれば確実に直せます。
運用面では、Response.HasStarted の考慮、ContentType の設定、ProblemDetails での統一、traceId の付与までやっておくと、障害対応のスピードと品質が一段上がります。

コメント