メインコンテンツまでスキップ

Global Exception Handling

本番環境の Web API では、ハンドルされなかった例外が生のスタックトレースやデフォルトの 500 エラーとしてクライアントに返ることを防ぐ必要があります。Global Exception Handling は、例外をアプリケーション全体で一元的にキャッチし、一貫したエラーレスポンスを返す仕組みです。

なぜグローバルな例外ハンドリングが必要か

各コントローラーや各サービスに try/catch を書くアプローチには次の問題があります。

  • 重複コード: すべてのエンドポイントで同じエラー処理を繰り返す
  • 一貫性の欠如: エラーレスポンスの形式が箇所ごとにバラバラになる
  • 漏れのリスク: 一箇所でも try/catch を忘れると生の例外が露出する

グローバルな例外ハンドリングを導入することで、これらの問題をまとめて解決できます。

アプローチ1: 例外ハンドリングミドルウェア (UseExceptionHandler)

ASP.NET Core 組み込みのミドルウェアを使う最もシンプルな方法です。

Program.cs
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 で複数のハンドラーをチェーンできます。

カスタム例外の定義

Exceptions/NotFoundException.cs
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;
}

ドメイン例外ハンドラーの実装

Infrastructure/GlobalExceptionHandler.cs
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; // 処理済み
}
}

フォールバックハンドラー(予期しない例外)

Infrastructure/UnhandledExceptionHandler.cs
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 への登録

Program.cs
builder.Services.AddExceptionHandler<GlobalExceptionHandler>();
builder.Services.AddExceptionHandler<UnhandledExceptionHandler>();

// Problem Details サポートを有効化
builder.Services.AddProblemDetails();

var app = builder.Build();

// 登録順にチェーンされる
app.UseExceptionHandler();
ハンドラーのチェーン

AddExceptionHandler は登録した順にチェーンされます。TryHandleAsyncfalse を返すと次のハンドラーが呼ばれます。ドメイン例外ハンドラーを先に、フォールバックハンドラーを最後に登録するのが定石です。

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人間が読める短いエラー説明
statusHTTP ステータスコード
detailこのエラーに固有の詳細説明
instanceエラーが発生したリクエストの URI

extensions フィールドを使って追加情報を含めることもできます(バリデーションエラーの詳細、トレースIDなど)。

ドメイン例外をサービス層でスロー

例外ハンドラーを実装したら、サービス層ではドメインロジックに集中し、適切な例外をスローするだけです。

Services/OrderService.cs
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 なしでシンプルに保てます。

Controllers/OrdersController.cs
[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);
}
}

開発環境と本番環境の切り替え

開発時はスタックトレースを詳細に表示し、本番では隠すように切り替えます。

Program.cs
if (app.Environment.IsDevelopment())
{
// 開発: 詳細なスタックトレースページ
app.UseDeveloperExceptionPage();
}
else
{
// 本番: グローバルハンドラー
app.UseExceptionHandler();
}

ベストプラクティスまとめ

プラクティス理由
IExceptionHandler を使う関心の分離、テスト容易性、複数ハンドラーのチェーン
Problem Details 形式で返すRFC 準拠・フロントエンドとの一貫したインターフェース
スタックトレースを本番で隠すセキュリティリスクの軽減
traceId を含める問題発生時に分散トレースと紐付けられる
ドメイン例外 ≠ ログエラーNotFoundException は Warning 相当。予期しない例外だけ Error ログ
フォールバックハンドラーを必ず用意する想定外の例外が生のまま返らないようにする