Spring Bootの@ControllerAdviceで集約例外処理を実装する

1. 概要

REST APIを作成するとき、各controllerが独自にtry-catchしてから独自の形式でエラーレスポンスを返していると、同じシステム内に数十種類もの異なるJSONエラー形式が存在することになります。@ControllerAdviceはこの問題を解決し、例外処理のロジックを一箇所で定義して、全体(または一部の)controllerに適用できるようにします。

この記事を読み終わると、標準的なGlobalExceptionHandlerの書き方、適切なHTTPステータスへの例外のマッピング、一貫したエラーボディの返却、そして動作を保証するテストの書き方が分かるようになります。

2. 集約例外処理がない場合の問題

Spring Bootを学び始めたときによく見かけるcontrollerの例を見てみましょう:

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

    private final OrderService orderService;

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

    @GetMapping("/{id}")
    public ResponseEntity<?> getOrder(@PathVariable Long id) {
        try {
            Order order = orderService.findById(id);
            return ResponseEntity.ok(order);
        } catch (OrderNotFoundException ex) {
            Map<String, String> body = new HashMap<>();
            body.put("error", ex.getMessage());
            return ResponseEntity.status(HttpStatus.NOT_FOUND).body(body);
        }
    }
}

問題点:

  • エラーボディを構築するロジック(Map<String, String>)がすべてのメソッド、すべてのcontrollerで繰り返される。
  • 新しいメソッドでtry-catchを書き忘れやすく、スタックトレースが露出した500エラーが返される可能性がある。
  • @Validのバリデーションエラーのような「共通」の例外を処理する場所がない。

注意: すべての例外をcontroller内のtry-catchで処理するべきではありません。controllerはserviceを呼び出すだけで、「例外をレスポンスに変換する」という責務は別の層に置くべきです。

3. @ControllerAdviceとは

@ControllerAdviceはクラスレベルのannotationで、そのクラスが複数のcontrollerを「アドバイス」(支援)することを示します。最も一般的な用途は@ExceptionHandlerを通じた例外処理です。

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(OrderNotFoundException.class)
    public ResponseEntity<ErrorResponse> handleOrderNotFound(OrderNotFoundException ex) {
        ErrorResponse body = new ErrorResponse(
                HttpStatus.NOT_FOUND.value(),
                ex.getMessage(),
                Instant.now()
        );
        return ResponseEntity.status(HttpStatus.NOT_FOUND).body(body);
    }
}

説明:

  • @RestControllerAdvice = @ControllerAdvice + @ResponseBody。REST APIではレスポンスボディが常にJSONにシリアライズされるため、REST APIに使用します。
  • @ExceptionHandler(OrderNotFoundException.class)はこのメソッドをOrderNotFoundException型の例外専用に登録します。OrderController内でtry-catchする必要がなくなります。
  • ErrorResponseは単純なrecordで、システム全体で統一されたエラー形式を定義します。

3.1. ErrorResponseとカスタム例外の定義

public record ErrorResponse(int status, String message, Instant timestamp) {
}
public class OrderNotFoundException extends RuntimeException {

    public OrderNotFoundException(Long orderId) {
        super("注文ID = " + orderId + " は見つかりません");
    }
}

OrderNotFoundExceptionはRuntimeExceptionを継承しているため、どこでもthrows宣言を書く必要がありません。OrderServiceでは見つからないときにthrowするだけです:

@Service
public class OrderService {

    private final OrderRepository orderRepository;

    public OrderService(OrderRepository orderRepository) {
        this.orderRepository = orderRepository;
    }

    public Order findById(Long id) {
        return orderRepository.findById(id)
                .orElseThrow(() -> new OrderNotFoundException(id));
    }
}

controllerは今ずっとシンプルになりました:

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

    private final OrderService orderService;

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

    @GetMapping("/{id}")
    public Order getOrder(@PathVariable Long id) {
        return orderService.findById(id);
    }
}

try-catchがなく、手動でResponseEntityを作成することもありません。GlobalExceptionHandlerが例外をレスポンスに変換するすべてを処理します。

4. 複数の例外タイプを処理する

実際には多くのエラータイプがあります:見つからない(404)、リクエストバリデーション失敗(400)、データ競合(409)、システムエラー(500)。@ExceptionHandlerは複数のメソッドを宣言でき、各メソッドが1つ以上の例外タイプを処理します。

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(OrderNotFoundException.class)
    public ResponseEntity<ErrorResponse> handleNotFound(OrderNotFoundException ex) {
        return buildResponse(HttpStatus.NOT_FOUND, ex.getMessage());
    }

    @ExceptionHandler(IllegalArgumentException.class)
    public ResponseEntity<ErrorResponse> handleBadRequest(IllegalArgumentException ex) {
        return buildResponse(HttpStatus.BAD_REQUEST, ex.getMessage());
    }

    @ExceptionHandler(DataIntegrityViolationException.class)
    public ResponseEntity<ErrorResponse> handleConflict(DataIntegrityViolationException ex) {
        return buildResponse(HttpStatus.CONFLICT, "データの競合が発生しました。もう一度お試しください");
    }

    @ExceptionHandler(Exception.class)
    public ResponseEntity<ErrorResponse> handleUnknown(Exception ex) {
        return buildResponse(HttpStatus.INTERNAL_SERVER_ERROR, "システムエラーが発生しました。サポートにお問い合わせください");
    }

    private ResponseEntity<ErrorResponse> buildResponse(HttpStatus status, String message) {
        ErrorResponse body = new ErrorResponse(status.value(), message, Instant.now());
        return ResponseEntity.status(status).body(body);
    }
}

説明:

  • 各@ExceptionHandlerは1つのエラータイプのみを処理し、Springは実際にthrowされた例外のクラス階層に基づいて最も適切なメソッドを選択します。
  • handleUnknownはException.classをキャッチし、最後の「安全網」として機能します。元のスタックトレースがclientに露出するのを防ぎます。これはほぼすべてのGlobalExceptionHandlerに必須です。
  • メソッド宣言の順序は優先度を決めません。Springは実際の例外に最も具体的にマッチする@ExceptionHandlerを選択します。

注意: 同じ@ControllerAdvice内で2つの@ExceptionHandlerが同じ例外タイプを宣言していると、アプリケーション起動時にSpringが曖昧なマッピングエラーを報告します。

5. @Validによるバリデーションエラーの処理

非常に一般的なシナリオ:Bean Validationを使用してリクエストボディをバリデーションします。バリデーション失敗時、Springは自動的にMethodArgumentNotValidExceptionをthrowするので、capturして読みやすくフォーマットします。

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-validation</artifactId>
</dependency>
public record CreateOrderRequest(
        @NotBlank(message = "顧客名は空にできません") String customerName,
        @Positive(message = "金額は0より大きい値を入力してください") BigDecimal amount
) {
}
@PostMapping
public Order createOrder(@Valid @RequestBody CreateOrderRequest request) {
    return orderService.create(request);
}

MethodArgumentNotValidException用のhandlerを追加します:

@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<Map<String, Object>> handleValidation(MethodArgumentNotValidException ex) {
    Map<String, String> fieldErrors = new LinkedHashMap<>();
    for (FieldError error : ex.getBindingResult().getFieldErrors()) {
        fieldErrors.put(error.getField(), error.getDefaultMessage());
    }

    Map<String, Object> body = new LinkedHashMap<>();
    body.put("status", HttpStatus.BAD_REQUEST.value());
    body.put("errors", fieldErrors);
    body.put("timestamp", Instant.now());

    return ResponseEntity.badRequest().body(body);
}

説明:

  • ex.getBindingResult().getFieldErrors()は、どのフィールドが不正であるか、その対応するメッセージを返します。これはあなたが@NotBlank、@Positiveで宣言したメッセージです。
  • LinkedHashMapを使用してフィールドの順序を保持し、デバッグ時に読みやすくします。

customerNameがなく、amountが負の値のリクエストを送信すると、以下が返されます:

HTTP/1.1 400 Bad Request
Content-Type: application/json

{
  "status": 400,
  "errors": {
    "customerName": "顧客名は空にできません",
    "amount": "金額は0より大きい値を入力してください"
  },
  "timestamp": "2026-10-05T10:15:30.123Z"
}

6. basePackagesで適用範囲を制限する

デフォルトでは、@ControllerAdviceはapplication contextのすべてのcontrollerに適用されます。複数のmodule(例:adminとpublic-api)があり、異なるエラー処理が必要な場合、適用範囲を制限できます:

@RestControllerAdvice(basePackages = "com.nonladev.shop.api.admin")
public class AdminExceptionHandler {

    @ExceptionHandler(AccessDeniedException.class)
    public ResponseEntity<ErrorResponse> handleAccessDenied(AccessDeniedException ex) {
        ErrorResponse body = new ErrorResponse(
                HttpStatus.FORBIDDEN.value(), "アクセス権限がありません", Instant.now());
        return ResponseEntity.status(HttpStatus.FORBIDDEN).body(body);
    }
}

basePackagesはこのadviceをcom.nonladev.shop.api.adminパッケージに位置するcontrollerのみに適用します。basePackages以外に、annotations(特定のannotationを持つcontrollerのみ)やassignableTypes(特定のclassのcontrollerのみ)も使用できます。

7. ProblemDetailを使用(RFC 7807準拠)— モダンなアプローチ

Spring Framework 6以降、RFC 7807準拠のレスポンスエラー標準を実装したProblemDetailが組み込まれています。カスタムErrorResponseを定義する必要はありません:

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(OrderNotFoundException.class)
    public ProblemDetail handleOrderNotFound(OrderNotFoundException ex) {
        ProblemDetail problem = ProblemDetail.forStatusAndDetail(
                HttpStatus.NOT_FOUND, ex.getMessage());
        problem.setTitle("Order Not Found");
        problem.setProperty("timestamp", Instant.now());
        return problem;
    }
}

レスポンスは以下の形式になります:

{
  "type": "about:blank",
  "title": "Order Not Found",
  "status": 404,
  "detail": "注文ID = 99 は見つかりません",
  "instance": null,
  "timestamp": "2026-10-05T10:20:00Z"
}

ProblemDetailはチームがRFC 7807準拠の統一基準に従いたい場合、またはRFC 7807を理解するclient/tooling(APIゲートウェイやOpenAPI toolsなど)と統合する場合に良い選択肢です。

8. GlobalExceptionHandlerのテスト

テストで正しいステータスコード、正しいボディが返されることを確認する必要があります。handlerが誤って編集されても気づけるようにします。

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
</dependency>
@WebMvcTest(OrderController.class)
@Import(GlobalExceptionHandler.class)
class OrderControllerExceptionTest {

    @Autowired
    private MockMvc mockMvc;

    @MockBean
    private OrderService orderService;

    @Test
    void shouldReturn404WhenOrderNotFound() throws Exception {
        given(orderService.findById(99L))
                .willThrow(new OrderNotFoundException(99L));

        mockMvc.perform(get("/api/orders/99"))
                .andExpect(status().isNotFound())
                .andExpect(jsonPath("$.message").value("注文ID = 99 は見つかりません"));
    }
}

またはSpring contextを使わずに、handler上で直接ユニットテストを実行することもできます:

class GlobalExceptionHandlerTest {

    private final GlobalExceptionHandler handler = new GlobalExceptionHandler();

    @Test
    void shouldMapOrderNotFoundTo404() {
        ResponseEntity<ErrorResponse> response =
                handler.handleOrderNotFound(new OrderNotFoundException(42L));

        assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
        assertThat(response.getBody().message())
                .isEqualTo("注文ID = 42 は見つかりません");
    }
}

2番目の方法はMockMvcを読み込む必要がないため高速です。例外をレスポンスにマップするロジックだけをテストしたい場合に適しており、ルーティングは関係ありません。

9. よくある間違い

  • Exception.classのhandlerを忘れる:予期しないエラー(NullPointerException、DB接続エラーなど)が発生した場合、clientはデフォルトのSpring Boot 500レスポンスとともにスタックトレースを受け取ります。内部情報が露出します。常に最後に「catch-all」handlerを置きましょう。
  • 例外メッセージに機密情報が含まれる:JPAの例外にはテーブル名やカラム名が含まれることがあります。DataIntegrityViolationExceptionからex.getMessage()を直接clientに返さず、一般的なメッセージを使用してください。
  • 複数の@ControllerAdviceが同じ例外を処理する:どれが実行されるのか不明瞭になりやすいです。複数のadviceを使う場合、@Orderで順序を定義するか、共通ロジックを1つのclassにまとめるのが最善です。
  • @ControllerAdviceにビジネスロジックを置く:adviceは例外をレスポンスに「翻訳する」だけです。ビジネス処理(エラー通知メール送信など)はserviceやevent listenerに置くべきです。

注意: @ControllerAdviceはcontroller method内(またはそこから呼び出される層)でthrowされた例外のみをcatchします。別のthread(@Asyncなど)やfilter(DispatcherServlet前に実行)でthrowされた例外は@ControllerAdviceでcatchされません。

10. まとめ

@ControllerAdviceと@ExceptionHandlerを組み合わせることで、エラー処理ロジックをcontrollerから完全に分離し、すべてのエラーレスポンスが統一形式で返されることを保証し、同時に機密情報がclientに露出するのを防ぐことができます。常にException.classをcatchする「安全網」handlerを用意し、@Valid + MethodArgumentNotValidExceptionをリクエストバリデーションに使用し、RFC 7807準拠を目指す場合はProblemDetailを検討してください。handlerのテストはservice同様に重要です。handler内の処理で、clientが何を見るか決まるからです。

spring-boot例外処理controller-advicejavarest-api