REST APIにおいて、エラーが発生した際に一貫性のあるレスポンスを返すことは、クライアント側の実装を容易にし、APIの信頼性を高めます。Spring Bootでは、@ControllerAdvice@ExceptionHandlerを組み合わせることで、アプリケーション全体でグローバルな例外処理を実現できます。本記事では、RFC 9457 Problem Details仕様に準拠したエラーレスポンスの設計と実装パターンを解説します。

実行環境と前提条件

本記事の内容を実践するにあたり、以下の環境を前提としています。

項目 バージョン・要件
Java 17以上
Spring Boot 3.4.x
ビルドツール Maven または Gradle
IDE VS Code(Extension Pack for Javaインストール済み)

また、本記事はSpring Boot REST API入門 - @RestControllerでCRUDエンドポイントを実装するおよびSpring Boot REST APIのバリデーション - Bean Validationで堅牢な入力検証を実装するの続編として、基本的なSpring Boot REST APIの知識があることを前提としています。

期待される学習成果

本記事を読み終えると、以下のことができるようになります。

  • @ControllerAdvice@ExceptionHandlerの仕組みを理解し、グローバル例外ハンドラを実装できる
  • RFC 9457 Problem Details形式のエラーレスポンスを返すAPIを設計できる
  • アプリケーション固有のカスタム例外クラスを適切な階層構造で設計できる
  • Spring BootのProblemDetailクラスを活用した標準準拠のエラーハンドリングを実装できる

なぜ統一的なエラーハンドリングが必要なのか

REST APIでは、様々な種類のエラーが発生する可能性があります。

  • リクエストのバリデーションエラー
  • リソースが見つからない
  • 認証・認可の失敗
  • ビジネスロジックの検証エラー
  • システム内部エラー

これらのエラーを各コントローラで個別に処理すると、以下の問題が発生します。

  1. コードの重複: 同じエラー処理ロジックが複数箇所に散在する
  2. 一貫性の欠如: エラーレスポンスの形式がエンドポイントごとに異なる
  3. 保守性の低下: エラー処理の変更が困難になる
  4. クライアント側の負担増: 複数のエラー形式に対応する必要がある

@ControllerAdviceを使用することで、これらの問題を解決し、アプリケーション全体で統一されたエラーハンドリングを実現できます。

@ControllerAdviceと@ExceptionHandlerの仕組み

@ControllerAdviceとは

@ControllerAdviceは、複数のコントローラにまたがる横断的な関心事を一箇所にまとめるためのアノテーションです。具体的には以下の機能を提供します。

  • 例外ハンドリング: @ExceptionHandlerメソッドによるグローバル例外処理
  • モデル属性のバインディング: @ModelAttributeメソッドによる共通データの提供
  • データバインディングの設定: @InitBinderメソッドによるバインディングのカスタマイズ

@RestControllerAdvice@ControllerAdvice@ResponseBodyを組み合わせたもので、REST APIのエラーハンドリングに適しています。

@ExceptionHandlerとは

@ExceptionHandlerは、特定の例外クラスが発生した際に呼び出されるメソッドを定義するアノテーションです。対象とする例外クラスを引数として指定します。

1
2
3
4
@ExceptionHandler(ResourceNotFoundException.class)
public ResponseEntity<ProblemDetail> handleResourceNotFound(ResourceNotFoundException ex) {
    // 例外処理ロジック
}

例外ハンドラの優先順位

複数の例外ハンドラが定義されている場合、Spring Bootは以下の順序で適切なハンドラを選択します。

  1. コントローラ内の@ExceptionHandler: 同一コントローラ内で定義されたハンドラが最優先
  2. @ControllerAdvice内の@ExceptionHandler: グローバルハンドラが次に評価される
  3. 例外クラスの継承階層: より具体的な例外クラスのハンドラが優先される
flowchart TD
    A[例外発生] --> B{コントローラ内に<br/>ハンドラあり?}
    B -->|Yes| C[コントローラ内<br/>ハンドラ実行]
    B -->|No| D{ControllerAdvice内に<br/>ハンドラあり?}
    D -->|Yes| E[具体的な例外<br/>ハンドラを選択]
    D -->|No| F[デフォルト<br/>エラーハンドラ]
    E --> G[ハンドラ実行]

RFC 9457 Problem Detailsとは

RFC 9457(旧RFC 7807)は、HTTP APIにおけるエラーレスポンスの標準形式を定義した仕様です。この仕様に従うことで、機械可読なエラー情報を一貫した形式で提供できます。

Problem Detailsの標準フィールド

RFC 9457では、以下の標準フィールドが定義されています。

フィールド 説明
type URI 問題の種類を識別するURI。デフォルトはabout:blank
title String 問題の種類を表す短い人間可読な要約
status Integer HTTPステータスコード
detail String この発生に特有の人間可読な説明
instance URI この問題の発生を識別するURI

Problem Detailsのレスポンス例

以下は、リソースが見つからない場合のProblem Details形式のレスポンス例です。

1
2
3
4
5
6
7
{
  "type": "https://api.example.com/problems/resource-not-found",
  "title": "Resource Not Found",
  "status": 404,
  "detail": "User with ID 12345 was not found",
  "instance": "/api/users/12345"
}

Content-Typeヘッダーにはapplication/problem+jsonを指定することが推奨されています。

拡張フィールド

RFC 9457では、標準フィールドに加えて問題固有の拡張フィールドを追加できます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
{
  "type": "https://api.example.com/problems/validation-error",
  "title": "Validation Error",
  "status": 400,
  "detail": "Request validation failed",
  "instance": "/api/users",
  "errors": [
    {
      "field": "email",
      "message": "must be a valid email address"
    },
    {
      "field": "age",
      "message": "must be greater than 0"
    }
  ]
}

Spring BootのProblemDetailクラス

Spring Framework 6.0以降では、RFC 9457をネイティブにサポートするProblemDetailクラスが提供されています。

ProblemDetailクラスの基本構造

ProblemDetailクラスは、RFC 9457の標準フィールドをすべてサポートしています。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
import org.springframework.http.ProblemDetail;
import java.net.URI;

ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(
    HttpStatus.NOT_FOUND,
    "User with ID 12345 was not found"
);
problemDetail.setType(URI.create("https://api.example.com/problems/resource-not-found"));
problemDetail.setTitle("Resource Not Found");
problemDetail.setInstance(URI.create("/api/users/12345"));

拡張プロパティの追加

ProblemDetailクラスのsetPropertyメソッドを使用して、カスタムフィールドを追加できます。

1
2
problemDetail.setProperty("timestamp", Instant.now());
problemDetail.setProperty("traceId", "abc-123-def-456");

追加されたプロパティは、JSONシリアライズ時にトップレベルのフィールドとして出力されます。

グローバル例外ハンドラの実装

基本的な@RestControllerAdviceの実装

Spring Bootでグローバル例外ハンドラを実装する際は、ResponseEntityExceptionHandlerを継承することをお勧めします。このクラスは、Spring MVCの標準例外に対するハンドラを既に実装しています。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
package com.example.demoapi.exception;

import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.ProblemDetail;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.context.request.WebRequest;
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;

import java.net.URI;
import java.time.Instant;

@RestControllerAdvice
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {

    private static final String PROBLEM_TYPE_BASE = "https://api.example.com/problems/";

    @ExceptionHandler(ResourceNotFoundException.class)
    public ProblemDetail handleResourceNotFound(ResourceNotFoundException ex, WebRequest request) {
        ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(
            HttpStatus.NOT_FOUND,
            ex.getMessage()
        );
        problemDetail.setType(URI.create(PROBLEM_TYPE_BASE + "resource-not-found"));
        problemDetail.setTitle("Resource Not Found");
        problemDetail.setProperty("timestamp", Instant.now());
        
        return problemDetail;
    }

    @ExceptionHandler(BusinessException.class)
    public ProblemDetail handleBusinessException(BusinessException ex, WebRequest request) {
        ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(
            HttpStatus.UNPROCESSABLE_ENTITY,
            ex.getMessage()
        );
        problemDetail.setType(URI.create(PROBLEM_TYPE_BASE + "business-error"));
        problemDetail.setTitle("Business Rule Violation");
        problemDetail.setProperty("errorCode", ex.getErrorCode());
        problemDetail.setProperty("timestamp", Instant.now());
        
        return problemDetail;
    }

    @ExceptionHandler(Exception.class)
    public ProblemDetail handleAllUncaughtException(Exception ex, WebRequest request) {
        ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(
            HttpStatus.INTERNAL_SERVER_ERROR,
            "An unexpected error occurred. Please try again later."
        );
        problemDetail.setType(URI.create(PROBLEM_TYPE_BASE + "internal-error"));
        problemDetail.setTitle("Internal Server Error");
        problemDetail.setProperty("timestamp", Instant.now());
        
        // 本番環境ではスタックトレースをログに出力し、レスポンスには含めない
        logger.error("Unexpected error occurred", ex);
        
        return problemDetail;
    }
}

バリデーションエラーのハンドリング

Bean Validationによるバリデーションエラーは、MethodArgumentNotValidExceptionとして発生します。ResponseEntityExceptionHandlerhandleMethodArgumentNotValidメソッドをオーバーライドして、カスタムレスポンスを返すことができます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
@RestControllerAdvice
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {

    @Override
    protected ResponseEntity<Object> handleMethodArgumentNotValid(
            MethodArgumentNotValidException ex,
            HttpHeaders headers,
            HttpStatusCode status,
            WebRequest request) {
        
        ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(
            HttpStatus.BAD_REQUEST,
            "Request validation failed"
        );
        problemDetail.setType(URI.create(PROBLEM_TYPE_BASE + "validation-error"));
        problemDetail.setTitle("Validation Error");
        
        // フィールドエラーを収集
        List<Map<String, String>> errors = ex.getBindingResult()
            .getFieldErrors()
            .stream()
            .map(error -> Map.of(
                "field", error.getField(),
                "message", error.getDefaultMessage() != null ? error.getDefaultMessage() : "Invalid value",
                "rejectedValue", String.valueOf(error.getRejectedValue())
            ))
            .toList();
        
        problemDetail.setProperty("errors", errors);
        problemDetail.setProperty("timestamp", Instant.now());
        
        return ResponseEntity.status(HttpStatus.BAD_REQUEST)
            .contentType(MediaType.APPLICATION_PROBLEM_JSON)
            .body(problemDetail);
    }
}

このハンドラは、以下のようなレスポンスを生成します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
{
  "type": "https://api.example.com/problems/validation-error",
  "title": "Validation Error",
  "status": 400,
  "detail": "Request validation failed",
  "errors": [
    {
      "field": "email",
      "message": "must be a well-formed email address",
      "rejectedValue": "invalid-email"
    },
    {
      "field": "name",
      "message": "must not be blank",
      "rejectedValue": ""
    }
  ],
  "timestamp": "2026-01-04T08:00:00Z"
}

カスタム例外クラスの設計パターン

アプリケーション固有の例外を適切に設計することで、エラーハンドリングの柔軟性と保守性が向上します。

例外クラスの階層構造

以下のような階層構造で例外クラスを設計することをお勧めします。

classDiagram
    class RuntimeException {
        <<java.lang>>
    }
    class ApplicationException {
        <<abstract>>
        -String errorCode
        -HttpStatus status
        +getErrorCode()
        +getStatus()
    }
    class ResourceNotFoundException {
        -String resourceName
        -String fieldName
        -Object fieldValue
    }
    class BusinessException {
        -String errorCode
    }
    class ValidationException {
        -List~FieldError~ errors
    }
    class DuplicateResourceException {
        -String resourceName
        -String fieldName
        -Object fieldValue
    }
    
    RuntimeException <|-- ApplicationException
    ApplicationException <|-- ResourceNotFoundException
    ApplicationException <|-- BusinessException
    ApplicationException <|-- ValidationException
    ApplicationException <|-- DuplicateResourceException

基底例外クラスの実装

すべてのアプリケーション例外の基底となるクラスを定義します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
package com.example.demoapi.exception;

import org.springframework.http.HttpStatus;

public abstract class ApplicationException extends RuntimeException {

    private final String errorCode;
    private final HttpStatus status;

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

    protected ApplicationException(String message, String errorCode, HttpStatus status, Throwable cause) {
        super(message, cause);
        this.errorCode = errorCode;
        this.status = status;
    }

    public String getErrorCode() {
        return errorCode;
    }

    public HttpStatus getStatus() {
        return status;
    }
}

リソース未発見例外の実装

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
package com.example.demoapi.exception;

import org.springframework.http.HttpStatus;

public class ResourceNotFoundException extends ApplicationException {

    private final String resourceName;
    private final String fieldName;
    private final Object fieldValue;

    public ResourceNotFoundException(String resourceName, String fieldName, Object fieldValue) {
        super(
            String.format("%s not found with %s: '%s'", resourceName, fieldName, fieldValue),
            "RESOURCE_NOT_FOUND",
            HttpStatus.NOT_FOUND
        );
        this.resourceName = resourceName;
        this.fieldName = fieldName;
        this.fieldValue = fieldValue;
    }

    public String getResourceName() {
        return resourceName;
    }

    public String getFieldName() {
        return fieldName;
    }

    public Object getFieldValue() {
        return fieldValue;
    }
}

ビジネス例外の実装

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
package com.example.demoapi.exception;

import org.springframework.http.HttpStatus;

public class BusinessException extends ApplicationException {

    public BusinessException(String message, String errorCode) {
        super(message, errorCode, HttpStatus.UNPROCESSABLE_ENTITY);
    }

    public BusinessException(String message, String errorCode, HttpStatus status) {
        super(message, errorCode, status);
    }
}

重複リソース例外の実装

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
package com.example.demoapi.exception;

import org.springframework.http.HttpStatus;

public class DuplicateResourceException extends ApplicationException {

    private final String resourceName;
    private final String fieldName;
    private final Object fieldValue;

    public DuplicateResourceException(String resourceName, String fieldName, Object fieldValue) {
        super(
            String.format("%s already exists with %s: '%s'", resourceName, fieldName, fieldValue),
            "DUPLICATE_RESOURCE",
            HttpStatus.CONFLICT
        );
        this.resourceName = resourceName;
        this.fieldName = fieldName;
        this.fieldValue = fieldValue;
    }

    public String getResourceName() {
        return resourceName;
    }

    public String getFieldName() {
        return fieldName;
    }

    public Object getFieldValue() {
        return fieldValue;
    }
}

統合的な例外ハンドラの完全実装

これまでの内容を統合した、本番環境で使用可能な例外ハンドラの完全な実装例を示します。

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
package com.example.demoapi.exception;

import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.MediaType;
import org.springframework.http.ProblemDetail;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.context.request.WebRequest;
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;

import java.net.URI;
import java.time.Instant;
import java.util.List;
import java.util.Map;

@RestControllerAdvice
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {

    private static final String PROBLEM_TYPE_BASE = "https://api.example.com/problems/";

    /**
     * アプリケーション固有の例外をハンドリング
     */
    @ExceptionHandler(ApplicationException.class)
    public ResponseEntity<ProblemDetail> handleApplicationException(
            ApplicationException ex, 
            WebRequest request) {
        
        ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(
            ex.getStatus(),
            ex.getMessage()
        );
        problemDetail.setType(URI.create(PROBLEM_TYPE_BASE + toKebabCase(ex.getErrorCode())));
        problemDetail.setTitle(toTitleCase(ex.getErrorCode()));
        problemDetail.setProperty("errorCode", ex.getErrorCode());
        problemDetail.setProperty("timestamp", Instant.now());
        
        // リソース未発見の場合、追加情報を含める
        if (ex instanceof ResourceNotFoundException rnfe) {
            problemDetail.setProperty("resourceName", rnfe.getResourceName());
            problemDetail.setProperty("fieldName", rnfe.getFieldName());
            problemDetail.setProperty("fieldValue", rnfe.getFieldValue());
        }
        
        // 重複リソースの場合、追加情報を含める
        if (ex instanceof DuplicateResourceException dre) {
            problemDetail.setProperty("resourceName", dre.getResourceName());
            problemDetail.setProperty("fieldName", dre.getFieldName());
            problemDetail.setProperty("fieldValue", dre.getFieldValue());
        }
        
        return ResponseEntity.status(ex.getStatus())
            .contentType(MediaType.APPLICATION_PROBLEM_JSON)
            .body(problemDetail);
    }

    /**
     * バリデーションエラーをハンドリング
     */
    @Override
    protected ResponseEntity<Object> handleMethodArgumentNotValid(
            MethodArgumentNotValidException ex,
            HttpHeaders headers,
            HttpStatusCode status,
            WebRequest request) {
        
        ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(
            HttpStatus.BAD_REQUEST,
            "Request validation failed"
        );
        problemDetail.setType(URI.create(PROBLEM_TYPE_BASE + "validation-error"));
        problemDetail.setTitle("Validation Error");
        
        List<Map<String, Object>> fieldErrors = ex.getBindingResult()
            .getFieldErrors()
            .stream()
            .map(error -> Map.<String, Object>of(
                "field", error.getField(),
                "message", error.getDefaultMessage() != null ? error.getDefaultMessage() : "Invalid value",
                "rejectedValue", error.getRejectedValue() != null ? error.getRejectedValue() : "null"
            ))
            .toList();
        
        List<Map<String, Object>> globalErrors = ex.getBindingResult()
            .getGlobalErrors()
            .stream()
            .map(error -> Map.<String, Object>of(
                "object", error.getObjectName(),
                "message", error.getDefaultMessage() != null ? error.getDefaultMessage() : "Invalid value"
            ))
            .toList();
        
        problemDetail.setProperty("fieldErrors", fieldErrors);
        problemDetail.setProperty("globalErrors", globalErrors);
        problemDetail.setProperty("timestamp", Instant.now());
        
        return ResponseEntity.status(HttpStatus.BAD_REQUEST)
            .contentType(MediaType.APPLICATION_PROBLEM_JSON)
            .body(problemDetail);
    }

    /**
     * 予期しない例外をハンドリング
     */
    @ExceptionHandler(Exception.class)
    public ResponseEntity<ProblemDetail> handleAllUncaughtException(
            Exception ex, 
            WebRequest request) {
        
        ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(
            HttpStatus.INTERNAL_SERVER_ERROR,
            "An unexpected error occurred. Please contact support if the problem persists."
        );
        problemDetail.setType(URI.create(PROBLEM_TYPE_BASE + "internal-error"));
        problemDetail.setTitle("Internal Server Error");
        problemDetail.setProperty("timestamp", Instant.now());
        
        // 本番環境ではスタックトレースをログに出力
        logger.error("Unexpected error occurred: {}", ex.getMessage(), ex);
        
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
            .contentType(MediaType.APPLICATION_PROBLEM_JSON)
            .body(problemDetail);
    }

    /**
     * エラーコードをケバブケースに変換
     */
    private String toKebabCase(String errorCode) {
        return errorCode.toLowerCase().replace("_", "-");
    }

    /**
     * エラーコードをタイトルケースに変換
     */
    private String toTitleCase(String errorCode) {
        String[] words = errorCode.toLowerCase().split("_");
        StringBuilder result = new StringBuilder();
        for (String word : words) {
            if (!result.isEmpty()) {
                result.append(" ");
            }
            result.append(Character.toUpperCase(word.charAt(0)));
            result.append(word.substring(1));
        }
        return result.toString();
    }
}

サービス層での例外の使用例

設計した例外クラスをサービス層で活用する例を示します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
package com.example.demoapi.service;

import com.example.demoapi.entity.User;
import com.example.demoapi.exception.DuplicateResourceException;
import com.example.demoapi.exception.ResourceNotFoundException;
import com.example.demoapi.repository.UserRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@Transactional
public class UserService {

    private final UserRepository userRepository;

    public UserService(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    public User findById(Long id) {
        return userRepository.findById(id)
            .orElseThrow(() -> new ResourceNotFoundException("User", "id", id));
    }

    public User findByEmail(String email) {
        return userRepository.findByEmail(email)
            .orElseThrow(() -> new ResourceNotFoundException("User", "email", email));
    }

    public User create(User user) {
        // メールアドレスの重複チェック
        if (userRepository.existsByEmail(user.getEmail())) {
            throw new DuplicateResourceException("User", "email", user.getEmail());
        }
        return userRepository.save(user);
    }

    public User update(Long id, User userDetails) {
        User user = findById(id);
        
        // 他のユーザーとメールアドレスが重複していないかチェック
        userRepository.findByEmail(userDetails.getEmail())
            .filter(existingUser -> !existingUser.getId().equals(id))
            .ifPresent(existingUser -> {
                throw new DuplicateResourceException("User", "email", userDetails.getEmail());
            });
        
        user.setName(userDetails.getName());
        user.setEmail(userDetails.getEmail());
        
        return userRepository.save(user);
    }

    public void delete(Long id) {
        User user = findById(id);
        userRepository.delete(user);
    }
}

コントローラの実装例

例外ハンドリングを活用したコントローラの実装例です。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
package com.example.demoapi.controller;

import com.example.demoapi.dto.UserRequest;
import com.example.demoapi.dto.UserResponse;
import com.example.demoapi.entity.User;
import com.example.demoapi.service.UserService;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.List;

@RestController
@RequestMapping("/api/users")
public class UserController {

    private final UserService userService;

    public UserController(UserService userService) {
        this.userService = userService;
    }

    @GetMapping("/{id}")
    public ResponseEntity<UserResponse> getUser(@PathVariable Long id) {
        User user = userService.findById(id);
        return ResponseEntity.ok(UserResponse.from(user));
    }

    @PostMapping
    public ResponseEntity<UserResponse> createUser(@Valid @RequestBody UserRequest request) {
        User user = request.toEntity();
        User created = userService.create(user);
        return ResponseEntity.status(HttpStatus.CREATED).body(UserResponse.from(created));
    }

    @PutMapping("/{id}")
    public ResponseEntity<UserResponse> updateUser(
            @PathVariable Long id, 
            @Valid @RequestBody UserRequest request) {
        User userDetails = request.toEntity();
        User updated = userService.update(id, userDetails);
        return ResponseEntity.ok(UserResponse.from(updated));
    }

    @DeleteMapping("/{id}")
    public ResponseEntity<Void> deleteUser(@PathVariable Long id) {
        userService.delete(id);
        return ResponseEntity.noContent().build();
    }
}

エラーレスポンスの動作確認

実装したエラーハンドリングの動作を確認してみましょう。

リソース未発見の場合

リクエスト:

1
curl -X GET http://localhost:8080/api/users/99999

レスポンス(404 Not Found):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
{
  "type": "https://api.example.com/problems/resource-not-found",
  "title": "Resource Not Found",
  "status": 404,
  "detail": "User not found with id: '99999'",
  "errorCode": "RESOURCE_NOT_FOUND",
  "resourceName": "User",
  "fieldName": "id",
  "fieldValue": 99999,
  "timestamp": "2026-01-04T08:00:00Z"
}

バリデーションエラーの場合

リクエスト:

1
2
3
curl -X POST http://localhost:8080/api/users \
  -H "Content-Type: application/json" \
  -d '{"name": "", "email": "invalid-email"}'

レスポンス(400 Bad Request):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
{
  "type": "https://api.example.com/problems/validation-error",
  "title": "Validation Error",
  "status": 400,
  "detail": "Request validation failed",
  "fieldErrors": [
    {
      "field": "name",
      "message": "must not be blank",
      "rejectedValue": ""
    },
    {
      "field": "email",
      "message": "must be a well-formed email address",
      "rejectedValue": "invalid-email"
    }
  ],
  "globalErrors": [],
  "timestamp": "2026-01-04T08:00:00Z"
}

重複リソースの場合

リクエスト:

1
2
3
curl -X POST http://localhost:8080/api/users \
  -H "Content-Type: application/json" \
  -d '{"name": "John Doe", "email": "existing@example.com"}'

レスポンス(409 Conflict):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
{
  "type": "https://api.example.com/problems/duplicate-resource",
  "title": "Duplicate Resource",
  "status": 409,
  "detail": "User already exists with email: 'existing@example.com'",
  "errorCode": "DUPLICATE_RESOURCE",
  "resourceName": "User",
  "fieldName": "email",
  "fieldValue": "existing@example.com",
  "timestamp": "2026-01-04T08:00:00Z"
}

ベストプラクティスと注意点

セキュリティ上の考慮事項

エラーレスポンスには、攻撃者に悪用される可能性のある情報を含めないよう注意が必要です。

  1. スタックトレースを含めない: 内部エラーの場合、スタックトレースはログに出力し、レスポンスには含めない
  2. 内部構造を露出しない: データベーススキーマやファイルパスなどの情報を含めない
  3. 一般的なメッセージを使用: 予期しないエラーには具体的な詳細ではなく、一般的なメッセージを返す

ログ出力との連携

エラー発生時には、適切なログ出力を行うことで、問題の調査と解決を容易にできます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
@ExceptionHandler(Exception.class)
public ResponseEntity<ProblemDetail> handleAllUncaughtException(
        Exception ex, 
        WebRequest request) {
    
    // トレースIDを生成(分散トレーシングと連携可能)
    String traceId = UUID.randomUUID().toString();
    
    // 詳細なログ出力
    logger.error("Unexpected error [traceId={}]: {}", traceId, ex.getMessage(), ex);
    
    ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(
        HttpStatus.INTERNAL_SERVER_ERROR,
        "An unexpected error occurred. Please contact support with trace ID: " + traceId
    );
    problemDetail.setProperty("traceId", traceId);
    
    return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
        .contentType(MediaType.APPLICATION_PROBLEM_JSON)
        .body(problemDetail);
}

テストの実装

例外ハンドリングのテストも重要です。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
@WebMvcTest(UserController.class)
class UserControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @MockBean
    private UserService userService;

    @Test
    void getUser_NotFound_ReturnsProblemDetail() throws Exception {
        // Given
        Long userId = 99999L;
        when(userService.findById(userId))
            .thenThrow(new ResourceNotFoundException("User", "id", userId));

        // When & Then
        mockMvc.perform(get("/api/users/{id}", userId))
            .andExpect(status().isNotFound())
            .andExpect(content().contentType(MediaType.APPLICATION_PROBLEM_JSON))
            .andExpect(jsonPath("$.type").value("https://api.example.com/problems/resource-not-found"))
            .andExpect(jsonPath("$.title").value("Resource Not Found"))
            .andExpect(jsonPath("$.status").value(404))
            .andExpect(jsonPath("$.errorCode").value("RESOURCE_NOT_FOUND"));
    }

    @Test
    void createUser_ValidationError_ReturnsProblemDetail() throws Exception {
        // Given
        String invalidRequest = """
            {
                "name": "",
                "email": "invalid-email"
            }
            """;

        // When & Then
        mockMvc.perform(post("/api/users")
                .contentType(MediaType.APPLICATION_JSON)
                .content(invalidRequest))
            .andExpect(status().isBadRequest())
            .andExpect(content().contentType(MediaType.APPLICATION_PROBLEM_JSON))
            .andExpect(jsonPath("$.type").value("https://api.example.com/problems/validation-error"))
            .andExpect(jsonPath("$.fieldErrors").isArray())
            .andExpect(jsonPath("$.fieldErrors.length()").value(2));
    }
}

まとめ

本記事では、Spring Boot REST APIにおける統一的なエラーハンドリングの実装について解説しました。

主なポイントは以下のとおりです。

  • @ControllerAdvice@ExceptionHandlerを使用してグローバル例外ハンドラを実装する
  • RFC 9457 Problem Details形式に準拠することで、機械可読で一貫性のあるエラーレスポンスを提供できる
  • Spring BootのProblemDetailクラスを活用して、標準準拠のレスポンスを簡単に生成できる
  • カスタム例外クラスを階層構造で設計することで、エラーハンドリングの柔軟性と保守性が向上する
  • セキュリティを考慮し、内部情報の漏洩を防ぐ

適切なエラーハンドリングを実装することで、APIの利用者にとって使いやすく、開発者にとって保守しやすいREST APIを構築できます。

参考リンク