【Spring Boot】@ControllerAdviceで例外ハンドリングを一元化する

Java

こんにちは、かつコーチです。

すべての@RestControllertry-catchを書き並べ、同じようなエラーレスポンス生成コードがコピペで増殖していく。

レイヤードアーキテクチャの記事でControllerを薄く保つ設計を扱いましたが、例外処理をControllerごとにベタ書きしていては、その原則が崩れてしまいます。

今回は、@ControllerAdviceを使って例外ハンドリングをアプリケーション全体で一元化する方法を整理します。

@ControllerAdviceを使わない場合の問題点

Controllerに散らばる例外処理

❌ Before:Controllerごとにtry-catchを書く

@RestController
@RequestMapping("/api/orders")
public class OrderController {

    private final OrderService orderService;

    public OrderController(OrderService orderService) {
        this.orderService = orderService;
    }

    @PostMapping
    public ResponseEntity<?> create(@RequestBody OrderRequest request) {
        try {
            Order order = orderService.placeOrder(request.getProductId(), request.getQuantity());
            return ResponseEntity.ok(order);
        } catch (OrderNotFoundException e) {
            return ResponseEntity.status(404).body(Map.of("message", e.getMessage()));
        } catch (IllegalStateException e) {
            return ResponseEntity.status(409).body(Map.of("message", e.getMessage()));
        }
    }
}

このコードには2つの問題があります。

1つは、同じ例外処理を別のControllerでも書くたびに、エラーレスポンスの形式がわずかにズレていく可能性があること。

もう1つは、Controllerの本来の責務である「リクエストを受けてレスポンスを返す」に、例外処理という別の関心事が混ざってしまうことです。

@ControllerAdviceによる一元化

手順1:例外ハンドラクラスを作成する

@RestControllerAdvice@ControllerAdviceにレスポンスをJSONとして返す機能を加えたアノテーション)を使い、例外処理を専用のクラスに集約します。

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(OrderNotFoundException.class)
    public ResponseEntity<ErrorResponse> handleNotFound(OrderNotFoundException e) {
        ErrorResponse body = new ErrorResponse("NOT_FOUND", e.getMessage());
        return ResponseEntity.status(HttpStatus.NOT_FOUND).body(body);
    }

    @ExceptionHandler(IllegalStateException.class)
    public ResponseEntity<ErrorResponse> handleConflict(IllegalStateException e) {
        ErrorResponse body = new ErrorResponse("CONFLICT", e.getMessage());
        return ResponseEntity.status(HttpStatus.CONFLICT).body(body);
    }

    @ExceptionHandler(Exception.class)
    public ResponseEntity<ErrorResponse> handleUnexpected(Exception e) {
        ErrorResponse body = new ErrorResponse("INTERNAL_ERROR", "予期しないエラーが発生しました");
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(body);
    }
}

public record ErrorResponse(String code, String message) {}

@ExceptionHandlerに指定した例外クラスがControllerの処理中にスローされると、Spring MVCが自動的にこのメソッドを呼び出してレスポンスを組み立てます。

手順2:Controller側から例外処理コードを取り除く

✅ After:Controllerは正常系だけに専念する

@RestController
@RequestMapping("/api/orders")
public class OrderController {

    private final OrderService orderService;

    public OrderController(OrderService orderService) {
        this.orderService = orderService;
    }

    @PostMapping
    public ResponseEntity<Order> create(@RequestBody OrderRequest request) {
        Order order = orderService.placeOrder(request.getProductId(), request.getQuantity());
        return ResponseEntity.ok(order);
    }
}

OrderService.placeOrder()OrderNotFoundExceptionIllegalStateExceptionをスローしても、Controllerは何もキャッチしません。

例外はGlobalExceptionHandlerまで伝播し、そこで一律に処理されます。

Controllerが増えても例外処理コードを増やす必要がなく、レスポンス形式のブレも構造的に防げます。

バリデーションエラーとの統合

Bean Validationの@Validが失敗した際にスローされるMethodArgumentNotValidExceptionも、同じGlobalExceptionHandlerにまとめられます。

@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResponse> handleValidation(MethodArgumentNotValidException e) {
    String message = e.getBindingResult().getFieldErrors().stream()
        .map(err -> err.getField() + ": " + err.getDefaultMessage())
        .collect(Collectors.joining(", "));
    return ResponseEntity.badRequest().body(new ErrorResponse("VALIDATION_ERROR", message));
}

こうすることで、入力チェックの失敗もビジネス例外も、同じErrorResponse形式で返せるようになります。

独自例外クラスとの組み合わせ設計

ドメイン例外は基底クラスで階層化する

例外の種類が増えてくると、@ExceptionHandlerの数もそれに比例して増えていきます。

筆者は実際のプロジェクトで、ビジネス例外を1つずつ個別にハンドリングしていたところ、20種類近くまで増えてGlobalExceptionHandlerが肥大化してしまった経験があります。

このとき有効なのが、業務例外の基底クラスを用意し、HTTPステータスをその例外自身に持たせる設計です。

public abstract class BusinessException extends RuntimeException {
    private final HttpStatus status;

    protected BusinessException(HttpStatus status, String message) {
        super(message);
        this.status = status;
    }

    public HttpStatus getStatus() {
        return status;
    }
}

public class OrderNotFoundException extends BusinessException {
    public OrderNotFoundException(Long id) {
        super(HttpStatus.NOT_FOUND, "注文が見つかりません: id=" + id);
    }
}
@ExceptionHandler(BusinessException.class)
public ResponseEntity<ErrorResponse> handleBusiness(BusinessException e) {
    return ResponseEntity.status(e.getStatus())
        .body(new ErrorResponse(e.getClass().getSimpleName(), e.getMessage()));
}

@ExceptionHandlerは1つの基底クラスにまとめられ、新しい業務例外を追加してもハンドラクラス自体を変更する必要がなくなりました。

応用・一歩先の使い方

ログ出力と外部通知の統合

GlobalExceptionHandlerは例外処理の一元窓口であるため、ログ出力や監視ツールへの通知もここに集約すると保守性が高まります。

@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleUnexpected(Exception e, HttpServletRequest request) {
    log.error("予期しないエラー: path={}", request.getRequestURI(), e);
    // Sentryなど外部監視ツールへの通知処理をここに集約できる
    return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
        .body(new ErrorResponse("INTERNAL_ERROR", "予期しないエラーが発生しました"));
}

原因不明の例外だけを対象にログレベルをERRORにし、業務例外はWARN程度に留めるなど、種類ごとにログの重みを変える設計も一般的です。

特定Controllerだけを対象にする

@ControllerAdvice(basePackages = "...")のように対象を絞ることもできますが、レスポンス形式の一貫性を優先するなら、アプリケーション全体を対象にする設計の方が事故が少なくなります。

まとめ

この記事のポイント

  • @RestControllerAdvice@ExceptionHandlerで、例外処理をアプリケーション全体で一元化できる
  • Controllerからtry-catchを排除でき、正常系のロジックだけに専念させられる
  • 業務例外は基底クラスで階層化し、HttpStatusを例外側に持たせるとハンドラの肥大化を防げる
  • バリデーションエラーも同じ仕組みでレスポンス形式を統一できる
  • ログ出力や外部通知もハンドラクラスに集約すると保守性が上がる

次に読むべき記事

  • レイヤードアーキテクチャの考え方
  • Spring Bootプロジェクトのベストプラクティス

タグ: Spring Boot, 上級者向け, 設計

タイトルとURLをコピーしました