Global Exception Handling
本番環境の Web API では、ハンドルされなかった例外が生のスタックトレースやデフォルトの 500 エラーとしてクライアントに返ることを防ぐ必要があります。Global Exception Handling は、例外をアプリケーション全体で一元的にキャッチし、一貫したエラーレスポンスを返す仕組みです。
なぜグローバルな例外ハンドリングが必要か
各コントローラーや各サービスに try/catch を書くアプローチには次の問題があります。
- 重複コード: すべてのエンドポイントで同じエラー処理を繰り返す
- 一貫性の欠如: エラーレスポンスの形式が箇所ごとにバラバラになる
- 漏れのリスク: 一箇所でも
try/catchを忘れると生の例外が露出する
グローバルな例外ハンドリングを導入することで、これらの問題をまとめて解決できます。
アプローチ1: 例外ハンドリングミドルウェア (UseExceptionHandler)
ASP.NET Core 組み込みのミドルウェアを使う最もシンプルな方法です。
var app = builder.Build();
app.UseExceptionHandler(errorApp =>
{
errorApp.Run(async context =>
{
var exceptionFeature = context.Features.Get<IExceptionHandlerFeature>();
var exception = exceptionFeature?.Error;
context.Response.ContentType = "application/problem+json";
context.Response.StatusCode = exception switch
{
NotFoundException => StatusCodes.Status404NotFound,
ValidationException => StatusCodes.Status422UnprocessableEntity,
UnauthorizedAccessException => StatusCodes.Status401Unauthorized,
_ => StatusCodes.Status500InternalServerError
};
var problem = new ProblemDetails
{
Status = context.Response.StatusCode,
Title = GetTitle(exception),
Detail = exception?.Message,
Instance = context.Request.Path
};
await context.Response.WriteAsJsonAsync(problem);
});
});
このアプローチは単純ですが、すべての例外処理ロジックを Program.cs に詰め込む形になります。
アプローチ2: IExceptionHandler (.NET 8+) — 推奨
.NET 8 で導入された IExceptionHandler インターフェースを実装することで、例外ハンドラーをクラスとして分離し、DI で複数のハンドラーをチェーンできます。
カスタム例外の定義
public sealed class NotFoundException(string message) : Exception(message);
public sealed class ValidationException(string message, IEnumerable<string> errors)
: Exception(message)
{
public IEnumerable<string> Errors { get; } = errors;
}
ドメイン例外ハンドラーの実装
using Microsoft.AspNetCore.Diagnostics;
using Microsoft.AspNetCore.Mvc;
public sealed class GlobalExceptionHandler(ILogger<GlobalExceptionHandler> logger)
: IExceptionHandler
{
public async ValueTask<bool> TryHandleAsync(
HttpContext httpContext,
Exception exception,
CancellationToken cancellationToken)
{
// このハンドラーで処理する例外の種類を判定
var (statusCode, title) = exception switch
{
NotFoundException => (StatusCodes.Status404NotFound, "Resource Not Found"),
ValidationException => (StatusCodes.Status422UnprocessableEntity, "Validation Failed"),
UnauthorizedAccessException => (StatusCodes.Status401Unauthorized, "Unauthorized"),
_ => (0, null) // 処理しない例外は 0 を返す
};
if (statusCode == 0)
{
// false を返すと次のハンドラーに処理が移る
return false;
}
logger.LogWarning(exception, "Domain exception occurred: {ExceptionType}", exception.GetType().Name);
var problemDetails = new ProblemDetails
{
Status = statusCode,
Title = title,
Detail = exception.Message,
Instance = $"{httpContext.Request.Method} {httpContext.Request.Path}"
};
if (exception is ValidationException validationEx)
{
problemDetails.Extensions["errors"] = validationEx.Errors;
}
httpContext.Response.StatusCode = statusCode;
await httpContext.Response.WriteAsJsonAsync(problemDetails, cancellationToken);
return true; // 処理済み
}
}
フォールバックハンドラー(予期しない例外)
public sealed class UnhandledExceptionHandler(ILogger<UnhandledExceptionHandler> logger)
: IExceptionHandler
{
public async ValueTask<bool> TryHandleAsync(
HttpContext httpContext,
Exception exception,
CancellationToken cancellationToken)
{
// スタックトレースを含むフルログを出力
logger.LogError(
exception,
"Unhandled exception. TraceId: {TraceId}",
httpContext.TraceIdentifier);
var problemDetails = new ProblemDetails
{
Status = StatusCodes.Status500InternalServerError,
Title = "An unexpected error occurred.",
Detail = "Please try again later or contact support.",
Instance = $"{httpContext.Request.Method} {httpContext.Request.Path}"
};
problemDetails.Extensions["traceId"] = httpContext.TraceIdentifier;
httpContext.Response.StatusCode = StatusCodes.Status500InternalServerError;
await httpContext.Response.WriteAsJsonAsync(problemDetails, cancellationToken);
return true;
}
}
DI への登録
builder.Services.AddExceptionHandler<GlobalExceptionHandler>();
builder.Services.AddExceptionHandler<UnhandledExceptionHandler>();
// Problem Details サポートを有効化
builder.Services.AddProblemDetails();
var app = builder.Build();
// 登録順にチェーンされる
app.UseExceptionHandler();
AddExceptionHandler は登録した順にチェーンされます。TryHandleAsync が false を返すと次のハンドラーが呼ばれます。ドメイン例外ハンドラーを先に、フォールバックハンドラーを最後に登録するのが定石です。
Problem Details (RFC 9457)
エラーレスポンスの形式は RFC 9457 (旧 RFC 7807) で標準化されています。ProblemDetails クラスはこの仕様に準拠しています。
{
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.5",
"title": "Resource Not Found",
"status": 404,
"detail": "Order with ID '42' was not found.",
"instance": "GET /api/orders/42",
"traceId": "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"
}
| フィールド | 説明 |
|---|---|
type | エラーの種類を識別する URI |
title | 人間が読める短いエラー説明 |
status | HTTP ステータスコード |
detail | このエラーに固有の詳細説明 |
instance | エラーが発生したリクエストの URI |
extensions フィールドを使って追加情報を含めることもできます(バリデーションエラーの詳細、トレースIDなど)。
ドメイン例外をサービス層でスロー
例外ハンドラーを実装したら、サービス層ではドメインロジックに集中し、適切な例外をスローするだけです。
public class OrderService(IOrderRepository repository)
{
public async Task<OrderDto> GetOrderAsync(int orderId, CancellationToken ct)
{
var order = await repository.FindAsync(orderId, ct);
if (order is null)
throw new NotFoundException($"Order with ID '{orderId}' was not found.");
return order.ToDto();
}
public async Task CreateOrderAsync(CreateOrderRequest request, CancellationToken ct)
{
var errors = new List<string>();
if (request.Quantity <= 0)
errors.Add("Quantity must be greater than zero.");
if (string.IsNullOrWhiteSpace(request.ProductCode))
errors.Add("ProductCode is required.");
if (errors.Count > 0)
throw new ValidationException("Invalid order request.", errors);
await repository.CreateAsync(request.ToEntity(), ct);
}
}
コントローラーは try/catch なしでシンプルに保てます。
[ApiController]
[Route("api/[controller]")]
public class OrdersController(OrderService orderService) : ControllerBase
{
[HttpGet("{id:int}")]
public async Task<IActionResult> GetOrder(int id, CancellationToken ct)
{
// 例外はグローバルハンドラーが処理する
var order = await orderService.GetOrderAsync(id, ct);
return Ok(order);
}
}
開発環境と本番環境の切り替え
開発時はスタックトレースを詳細に表示し、本番では隠すように切り替えます。
if (app.Environment.IsDevelopment())
{
// 開発: 詳細なスタックトレースページ
app.UseDeveloperExceptionPage();
}
else
{
// 本番: グローバルハンドラー
app.UseExceptionHandler();
}
ベストプラクティスまとめ
| プラクティス | 理由 |
|---|---|
IExceptionHandler を使う | 関心の分離、テスト容易性、複数ハンドラーのチェーン |
| Problem Details 形式で返す | RFC 準拠・フロントエンドとの一貫したインターフェース |
| スタックトレースを本番で隠す | セキュリティリスクの軽減 |
traceId を含める | 問題発生時に分散トレースと紐付けられる |
| ドメイン例外 ≠ ログエラー | NotFoundException は Warning 相当。予期しない例外だけ Error ログ |
| フォールバックハンドラーを必ず用意する | 想定外の例外が生のまま返らないようにする |