REST APIにおいて、クライアントからのリクエストデータを適切に検証することは、アプリケーションの堅牢性とセキュリティを確保する上で不可欠です。Spring BootはJakarta Bean Validation(旧Java Bean Validation)を統合しており、アノテーションベースで宣言的にバリデーションルールを定義できます。本記事では、標準バリデーションアノテーションの使い方からカスタムバリデーションの作成、グループバリデーションによる条件分岐まで、実践的なバリデーション実装を解説します。

実行環境と前提条件

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

項目 バージョン・要件
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 3.x では、spring-boot-starter-validationを追加することでBean Validationが利用可能になります。

Mavenの場合

1
2
3
4
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-validation</artifactId>
</dependency>

Gradleの場合

1
implementation 'org.springframework.boot:spring-boot-starter-validation'

この依存関係により、Jakarta Bean Validation API(jakarta.validationパッケージ)と参照実装であるHibernate Validatorが自動的に追加されます。

標準バリデーションアノテーション一覧

Jakarta Bean Validation 3.0では、以下の標準アノテーションが提供されています。これらはすべてjakarta.validation.constraintsパッケージに含まれています。

Null/非Null検証

アノテーション 説明 対象型
@Null nullであることを検証 任意の型
@NotNull nullでないことを検証 任意の型
@NotEmpty nullでなく、空でないことを検証 CharSequence, Collection, Map, 配列
@NotBlank nullでなく、空白文字以外を含むことを検証 CharSequence

数値検証

アノテーション 説明 対象型
@Min(value) 指定値以上であることを検証 整数型(BigDecimal, BigInteger, byte, short, int, long)
@Max(value) 指定値以下であることを検証 整数型
@DecimalMin(value) 指定値以上であることを検証(文字列で指定) 数値型、CharSequence
@DecimalMax(value) 指定値以下であることを検証(文字列で指定) 数値型、CharSequence
@Positive 正の数であることを検証(0は無効) 数値型
@PositiveOrZero 0以上であることを検証 数値型
@Negative 負の数であることを検証(0は無効) 数値型
@NegativeOrZero 0以下であることを検証 数値型
@Digits(integer, fraction) 整数部と小数部の桁数を検証 数値型、CharSequence

サイズ・長さ検証

アノテーション 説明 対象型
@Size(min, max) サイズが指定範囲内であることを検証 CharSequence, Collection, Map, 配列

日時検証

アノテーション 説明 対象型
@Past 過去の日時であることを検証 Date, Calendar, java.time.*
@PastOrPresent 過去または現在の日時であることを検証 Date, Calendar, java.time.*
@Future 未来の日時であることを検証 Date, Calendar, java.time.*
@FutureOrPresent 現在または未来の日時であることを検証 Date, Calendar, java.time.*

パターン検証

アノテーション 説明 対象型
@Pattern(regexp) 正規表現にマッチすることを検証 CharSequence
@Email メールアドレス形式であることを検証 CharSequence

論理値検証

アノテーション 説明 対象型
@AssertTrue trueであることを検証 boolean, Boolean
@AssertFalse falseであることを検証 boolean, Boolean

DTOへのバリデーションアノテーション適用

リクエストボディを受け取るDTOクラスにバリデーションアノテーションを適用します。

ユーザー登録用DTOの例

 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
package com.example.demoapi.dto;

import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Past;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;

import java.time.LocalDate;

public class UserCreateRequest {

    @NotBlank(message = "ユーザー名は必須です")
    @Size(min = 3, max = 50, message = "ユーザー名は3文字以上50文字以下で入力してください")
    private String username;

    @NotBlank(message = "メールアドレスは必須です")
    @Email(message = "有効なメールアドレスを入力してください")
    private String email;

    @NotBlank(message = "パスワードは必須です")
    @Size(min = 8, max = 100, message = "パスワードは8文字以上100文字以下で入力してください")
    @Pattern(
        regexp = "^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d).+$",
        message = "パスワードは大文字、小文字、数字をそれぞれ1文字以上含める必要があります"
    )
    private String password;

    @NotNull(message = "年齢は必須です")
    @Min(value = 0, message = "年齢は0以上で入力してください")
    private Integer age;

    @Past(message = "生年月日は過去の日付を入力してください")
    private LocalDate birthDate;

    @Pattern(regexp = "^\\d{3}-\\d{4}-\\d{4}$", message = "電話番号はXXX-XXXX-XXXX形式で入力してください")
    private String phoneNumber;

    // コンストラクタ、ゲッター、セッター
    public UserCreateRequest() {
    }

    public String getUsername() {
        return username;
    }

    public void setUsername(String username) {
        this.username = username;
    }

    public String getEmail() {
        return email;
    }

    public void setEmail(String email) {
        this.email = email;
    }

    public String getPassword() {
        return password;
    }

    public void setPassword(String password) {
        this.password = password;
    }

    public Integer getAge() {
        return age;
    }

    public void setAge(Integer age) {
        this.age = age;
    }

    public LocalDate getBirthDate() {
        return birthDate;
    }

    public void setBirthDate(LocalDate birthDate) {
        this.birthDate = birthDate;
    }

    public String getPhoneNumber() {
        return phoneNumber;
    }

    public void setPhoneNumber(String phoneNumber) {
        this.phoneNumber = phoneNumber;
    }
}

コントローラーでのバリデーション適用

DTOにバリデーションアノテーションを定義しただけでは検証は実行されません。コントローラーのメソッドパラメータに@Validまたは@Validatedを付与する必要があります。

@Validと@Validatedの違い

項目 @Valid @Validated
パッケージ jakarta.validation org.springframework.validation.annotation
グループ指定 不可 可能
ネストしたオブジェクトの検証 可能 可能
用途 標準的なバリデーション グループバリデーション使用時

コントローラーの実装例

 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
package com.example.demoapi.controller;

import com.example.demoapi.dto.UserCreateRequest;
import com.example.demoapi.dto.UserResponse;
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.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

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

    private final UserService userService;

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

    @PostMapping
    public ResponseEntity<UserResponse> createUser(
            @Valid @RequestBody UserCreateRequest request) {
        UserResponse response = userService.createUser(request);
        return ResponseEntity.status(HttpStatus.CREATED).body(response);
    }
}

@Validを付与することで、リクエストボディのデシリアライズ後にバリデーションが実行されます。バリデーションエラーが発生した場合、MethodArgumentNotValidExceptionがスローされます。

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

バリデーションエラーが発生した際、デフォルトでは400 Bad Requestが返されますが、エラー詳細がクライアントにとってわかりやすい形式ではありません。@RestControllerAdviceを使ってカスタムエラーレスポンスを返却します。

エラーレスポンスDTOの定義

 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
package com.example.demoapi.dto;

import java.time.LocalDateTime;
import java.util.List;

public class ErrorResponse {

    private LocalDateTime timestamp;
    private int status;
    private String error;
    private String message;
    private String path;
    private List<FieldError> fieldErrors;

    public ErrorResponse() {
        this.timestamp = LocalDateTime.now();
    }

    public static class FieldError {
        private String field;
        private String message;
        private Object rejectedValue;

        public FieldError(String field, String message, Object rejectedValue) {
            this.field = field;
            this.message = message;
            this.rejectedValue = rejectedValue;
        }

        // ゲッター
        public String getField() {
            return field;
        }

        public String getMessage() {
            return message;
        }

        public Object getRejectedValue() {
            return rejectedValue;
        }
    }

    // ゲッター、セッター
    public LocalDateTime getTimestamp() {
        return timestamp;
    }

    public void setTimestamp(LocalDateTime timestamp) {
        this.timestamp = timestamp;
    }

    public int getStatus() {
        return status;
    }

    public void setStatus(int status) {
        this.status = status;
    }

    public String getError() {
        return error;
    }

    public void setError(String error) {
        this.error = error;
    }

    public String getMessage() {
        return message;
    }

    public void setMessage(String message) {
        this.message = message;
    }

    public String getPath() {
        return path;
    }

    public void setPath(String path) {
        this.path = path;
    }

    public List<FieldError> getFieldErrors() {
        return fieldErrors;
    }

    public void setFieldErrors(List<FieldError> fieldErrors) {
        this.fieldErrors = fieldErrors;
    }
}

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

 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
package com.example.demoapi.exception;

import com.example.demoapi.dto.ErrorResponse;

import jakarta.servlet.http.HttpServletRequest;

import org.springframework.http.HttpStatus;
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 java.util.List;

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ErrorResponse> handleValidationException(
            MethodArgumentNotValidException ex,
            HttpServletRequest request) {

        List<ErrorResponse.FieldError> fieldErrors = ex.getBindingResult()
                .getFieldErrors()
                .stream()
                .map(error -> new ErrorResponse.FieldError(
                        error.getField(),
                        error.getDefaultMessage(),
                        error.getRejectedValue()))
                .toList();

        ErrorResponse response = new ErrorResponse();
        response.setStatus(HttpStatus.BAD_REQUEST.value());
        response.setError("Validation Failed");
        response.setMessage("入力値に誤りがあります");
        response.setPath(request.getRequestURI());
        response.setFieldErrors(fieldErrors);

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

エラーレスポンスの例

バリデーションエラーが発生した場合、以下のようなJSON形式でレスポンスが返却されます。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
{
  "timestamp": "2026-01-04T17:30:00",
  "status": 400,
  "error": "Validation Failed",
  "message": "入力値に誤りがあります",
  "path": "/api/users",
  "fieldErrors": [
    {
      "field": "username",
      "message": "ユーザー名は3文字以上50文字以下で入力してください",
      "rejectedValue": "ab"
    },
    {
      "field": "email",
      "message": "有効なメールアドレスを入力してください",
      "rejectedValue": "invalid-email"
    }
  ]
}

ネストしたオブジェクトのバリデーション

DTOがネストしたオブジェクトを持つ場合、@Validアノテーションを使って再帰的にバリデーションを適用できます。

住所情報を含むDTOの例

 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
package com.example.demoapi.dto;

import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;

public class UserWithAddressRequest {

    @NotBlank(message = "ユーザー名は必須です")
    private String username;

    @NotNull(message = "住所は必須です")
    @Valid  // ネストしたオブジェクトのバリデーションを有効化
    private AddressRequest address;

    // ゲッター、セッター
    public String getUsername() {
        return username;
    }

    public void setUsername(String username) {
        this.username = username;
    }

    public AddressRequest getAddress() {
        return address;
    }

    public void setAddress(AddressRequest address) {
        this.address = address;
    }

    public static class AddressRequest {

        @NotBlank(message = "郵便番号は必須です")
        @Pattern(regexp = "^\\d{3}-\\d{4}$", message = "郵便番号はXXX-XXXX形式で入力してください")
        private String postalCode;

        @NotBlank(message = "都道府県は必須です")
        private String prefecture;

        @NotBlank(message = "市区町村は必須です")
        @Size(max = 100, message = "市区町村は100文字以下で入力してください")
        private String city;

        @Size(max = 200, message = "番地は200文字以下で入力してください")
        private String street;

        // ゲッター、セッター
        public String getPostalCode() {
            return postalCode;
        }

        public void setPostalCode(String postalCode) {
            this.postalCode = postalCode;
        }

        public String getPrefecture() {
            return prefecture;
        }

        public void setPrefecture(String prefecture) {
            this.prefecture = prefecture;
        }

        public String getCity() {
            return city;
        }

        public void setCity(String city) {
            this.city = city;
        }

        public String getStreet() {
            return street;
        }

        public void setStreet(String street) {
            this.street = street;
        }
    }
}

@Validを付与したフィールドは、そのオブジェクト内のバリデーションアノテーションも検証対象となります。

コレクション要素のバリデーション

リストやマップの各要素に対してバリデーションを適用することも可能です。

リスト要素のバリデーション

 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
package com.example.demoapi.dto;

import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.Size;

import java.util.List;

public class OrderRequest {

    @NotBlank(message = "顧客コードは必須です")
    private String customerCode;

    @NotEmpty(message = "注文明細は1件以上必要です")
    @Size(max = 100, message = "注文明細は100件以下にしてください")
    private List<@Valid OrderItemRequest> items;

    // ゲッター、セッター
    public String getCustomerCode() {
        return customerCode;
    }

    public void setCustomerCode(String customerCode) {
        this.customerCode = customerCode;
    }

    public List<OrderItemRequest> getItems() {
        return items;
    }

    public void setItems(List<OrderItemRequest> items) {
        this.items = items;
    }

    public static class OrderItemRequest {

        @NotBlank(message = "商品コードは必須です")
        private String productCode;

        @jakarta.validation.constraints.Min(value = 1, message = "数量は1以上で入力してください")
        private int quantity;

        // ゲッター、セッター
        public String getProductCode() {
            return productCode;
        }

        public void setProductCode(String productCode) {
            this.productCode = productCode;
        }

        public int getQuantity() {
            return quantity;
        }

        public void setQuantity(int quantity) {
            this.quantity = quantity;
        }
    }
}

カスタムバリデーションアノテーションの作成

標準アノテーションでは対応できない検証ロジックが必要な場合、カスタムバリデーションアノテーションを作成します。

カスタムアノテーションの構成要素

カスタムバリデーションは以下の2つの要素で構成されます。

  1. アノテーション定義: 制約を宣言するアノテーション
  2. バリデータ実装: 検証ロジックを実装するConstraintValidator

電話番号検証アノテーションの例

アノテーション定義

 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
package com.example.demoapi.validation;

import jakarta.validation.Constraint;
import jakarta.validation.Payload;

import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Documented
@Constraint(validatedBy = PhoneNumberValidator.class)
@Target({ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
public @interface PhoneNumber {

    String message() default "有効な電話番号を入力してください";

    Class<?>[] groups() default {};

    Class<? extends Payload>[] payload() default {};

    // カスタム属性: 電話番号の種類を指定
    PhoneType type() default PhoneType.ANY;

    enum PhoneType {
        MOBILE,     // 携帯電話
        LANDLINE,   // 固定電話
        ANY         // どちらでも可
    }
}

バリデータ実装

 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
package com.example.demoapi.validation;

import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;

import java.util.regex.Pattern;

public class PhoneNumberValidator implements ConstraintValidator<PhoneNumber, String> {

    private static final Pattern MOBILE_PATTERN = 
            Pattern.compile("^0[789]0-\\d{4}-\\d{4}$");
    private static final Pattern LANDLINE_PATTERN = 
            Pattern.compile("^0\\d{1,4}-\\d{1,4}-\\d{4}$");

    private PhoneNumber.PhoneType phoneType;

    @Override
    public void initialize(PhoneNumber constraintAnnotation) {
        this.phoneType = constraintAnnotation.type();
    }

    @Override
    public boolean isValid(String value, ConstraintValidatorContext context) {
        // nullは他の制約(@NotNullなど)で検証するため、ここではtrueを返す
        if (value == null || value.isBlank()) {
            return true;
        }

        return switch (phoneType) {
            case MOBILE -> MOBILE_PATTERN.matcher(value).matches();
            case LANDLINE -> LANDLINE_PATTERN.matcher(value).matches();
            case ANY -> MOBILE_PATTERN.matcher(value).matches() 
                    || LANDLINE_PATTERN.matcher(value).matches();
        };
    }
}

カスタムアノテーションの使用

 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
package com.example.demoapi.dto;

import com.example.demoapi.validation.PhoneNumber;
import com.example.demoapi.validation.PhoneNumber.PhoneType;

import jakarta.validation.constraints.NotBlank;

public class ContactRequest {

    @NotBlank(message = "名前は必須です")
    private String name;

    @PhoneNumber(type = PhoneType.MOBILE, message = "携帯電話番号を入力してください")
    private String mobilePhone;

    @PhoneNumber(type = PhoneType.LANDLINE, message = "固定電話番号を入力してください")
    private String landlinePhone;

    // ゲッター、セッター
    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public String getMobilePhone() {
        return mobilePhone;
    }

    public void setMobilePhone(String mobilePhone) {
        this.mobilePhone = mobilePhone;
    }

    public String getLandlinePhone() {
        return landlinePhone;
    }

    public void setLandlinePhone(String landlinePhone) {
        this.landlinePhone = landlinePhone;
    }
}

クラスレベルバリデーションの実装

複数のフィールド間の整合性を検証する場合、クラスレベルのバリデーションを使用します。

パスワード確認用アノテーションの例

アノテーション定義

 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
package com.example.demoapi.validation;

import jakarta.validation.Constraint;
import jakarta.validation.Payload;

import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Documented
@Constraint(validatedBy = PasswordMatchValidator.class)
@Target(ElementType.TYPE)  // クラスレベルに適用
@Retention(RetentionPolicy.RUNTIME)
public @interface PasswordMatch {

    String message() default "パスワードが一致しません";

    Class<?>[] groups() default {};

    Class<? extends Payload>[] payload() default {};

    String passwordField() default "password";

    String confirmPasswordField() default "confirmPassword";
}

バリデータ実装

 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
package com.example.demoapi.validation;

import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;

import org.springframework.beans.BeanWrapper;
import org.springframework.beans.BeanWrapperImpl;

public class PasswordMatchValidator implements ConstraintValidator<PasswordMatch, Object> {

    private String passwordField;
    private String confirmPasswordField;

    @Override
    public void initialize(PasswordMatch constraintAnnotation) {
        this.passwordField = constraintAnnotation.passwordField();
        this.confirmPasswordField = constraintAnnotation.confirmPasswordField();
    }

    @Override
    public boolean isValid(Object value, ConstraintValidatorContext context) {
        if (value == null) {
            return true;
        }

        BeanWrapper beanWrapper = new BeanWrapperImpl(value);
        Object password = beanWrapper.getPropertyValue(passwordField);
        Object confirmPassword = beanWrapper.getPropertyValue(confirmPasswordField);

        boolean isValid = (password == null && confirmPassword == null)
                || (password != null && password.equals(confirmPassword));

        if (!isValid) {
            // デフォルトのエラーメッセージを無効化
            context.disableDefaultConstraintViolation();
            // confirmPasswordフィールドにエラーを紐づけ
            context.buildConstraintViolationWithTemplate(context.getDefaultConstraintMessageTemplate())
                    .addPropertyNode(confirmPasswordField)
                    .addConstraintViolation();
        }

        return isValid;
    }
}

クラスレベルアノテーションの使用

 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
package com.example.demoapi.dto;

import com.example.demoapi.validation.PasswordMatch;

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;

@PasswordMatch(message = "パスワードと確認用パスワードが一致しません")
public class PasswordChangeRequest {

    @NotBlank(message = "現在のパスワードは必須です")
    private String currentPassword;

    @NotBlank(message = "新しいパスワードは必須です")
    @Size(min = 8, max = 100, message = "パスワードは8文字以上100文字以下で入力してください")
    private String password;

    @NotBlank(message = "確認用パスワードは必須です")
    private String confirmPassword;

    // ゲッター、セッター
    public String getCurrentPassword() {
        return currentPassword;
    }

    public void setCurrentPassword(String currentPassword) {
        this.currentPassword = currentPassword;
    }

    public String getPassword() {
        return password;
    }

    public void setPassword(String password) {
        this.password = password;
    }

    public String getConfirmPassword() {
        return confirmPassword;
    }

    public void setConfirmPassword(String confirmPassword) {
        this.confirmPassword = confirmPassword;
    }
}

グループバリデーションによる条件分岐

同じDTOを複数の操作(登録、更新など)で使用する場合、操作ごとに異なるバリデーションルールを適用したいことがあります。グループバリデーションを使うことで、この要件を実現できます。

グループインターフェースの定義

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

import jakarta.validation.groups.Default;

public interface ValidationGroups {

    // 登録時のバリデーショングループ
    interface Create extends Default {
    }

    // 更新時のバリデーショングループ
    interface Update extends Default {
    }
}

グループを指定したDTOの定義

 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
package com.example.demoapi.dto;

import com.example.demoapi.validation.group.ValidationGroups.Create;
import com.example.demoapi.validation.group.ValidationGroups.Update;

import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Null;
import jakarta.validation.constraints.Size;

public class UserRequest {

    // 登録時はnull必須、更新時は必須
    @Null(groups = Create.class, message = "登録時にIDは指定できません")
    @NotNull(groups = Update.class, message = "更新時はIDが必須です")
    private Long id;

    @NotBlank(message = "ユーザー名は必須です")
    @Size(min = 3, max = 50, message = "ユーザー名は3文字以上50文字以下で入力してください")
    private String username;

    @NotBlank(message = "メールアドレスは必須です")
    @Email(message = "有効なメールアドレスを入力してください")
    private String email;

    // 登録時は必須、更新時は任意(nullの場合は変更なし)
    @NotBlank(groups = Create.class, message = "パスワードは必須です")
    @Size(min = 8, max = 100, message = "パスワードは8文字以上100文字以下で入力してください")
    private String password;

    // ゲッター、セッター
    public Long getId() {
        return id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    public String getUsername() {
        return username;
    }

    public void setUsername(String username) {
        this.username = username;
    }

    public String getEmail() {
        return email;
    }

    public void setEmail(String email) {
        this.email = email;
    }

    public String getPassword() {
        return password;
    }

    public void setPassword(String password) {
        this.password = password;
    }
}

コントローラーでのグループ指定

グループバリデーションを使用する場合は、@Validではなく@Validatedを使用し、適用するグループを指定します。

 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
package com.example.demoapi.controller;

import com.example.demoapi.dto.UserRequest;
import com.example.demoapi.dto.UserResponse;
import com.example.demoapi.service.UserService;
import com.example.demoapi.validation.group.ValidationGroups.Create;
import com.example.demoapi.validation.group.ValidationGroups.Update;

import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

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

    private final UserService userService;

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

    @PostMapping
    public ResponseEntity<UserResponse> createUser(
            @Validated(Create.class) @RequestBody UserRequest request) {
        UserResponse response = userService.createUser(request);
        return ResponseEntity.status(HttpStatus.CREATED).body(response);
    }

    @PutMapping("/{id}")
    public ResponseEntity<UserResponse> updateUser(
            @PathVariable Long id,
            @Validated(Update.class) @RequestBody UserRequest request) {
        request.setId(id);
        UserResponse response = userService.updateUser(request);
        return ResponseEntity.ok(response);
    }
}

グループシーケンスによる順序制御

バリデーションを段階的に実行し、前段階でエラーがあれば後続のバリデーションをスキップしたい場合、@GroupSequenceを使用します。

グループシーケンスの定義

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
package com.example.demoapi.validation.group;

import jakarta.validation.GroupSequence;
import jakarta.validation.groups.Default;

public interface OrderedValidation {

    interface First {
    }

    interface Second {
    }

    interface Third {
    }

    @GroupSequence({Default.class, First.class, Second.class, Third.class})
    interface OrderedChecks {
    }
}

シーケンスを適用したDTOの例

 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
package com.example.demoapi.dto;

import com.example.demoapi.validation.group.OrderedValidation.First;
import com.example.demoapi.validation.group.OrderedValidation.Second;

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;

public class RegistrationRequest {

    // 第1段階: 必須チェック
    @NotBlank(message = "ユーザーIDは必須です")
    // 第2段階: 形式チェック
    @Pattern(
        regexp = "^[a-zA-Z][a-zA-Z0-9_]*$",
        message = "ユーザーIDは英字で始まり、英数字とアンダースコアのみ使用できます",
        groups = First.class
    )
    // 第3段階: 長さチェック
    @Size(min = 5, max = 20, message = "ユーザーIDは5文字以上20文字以下で入力してください", groups = Second.class)
    private String userId;

    // ゲッター、セッター
    public String getUserId() {
        return userId;
    }

    public void setUserId(String userId) {
        this.userId = userId;
    }
}

パスパラメータとクエリパラメータのバリデーション

リクエストボディだけでなく、パスパラメータやクエリパラメータにもバリデーションを適用できます。この場合、コントローラークラスに@Validatedを付与します。

パラメータバリデーションの例

 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
package com.example.demoapi.controller;

import com.example.demoapi.dto.UserResponse;
import com.example.demoapi.service.UserService;

import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.Positive;

import org.springframework.http.ResponseEntity;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import java.util.List;

@RestController
@RequestMapping("/api/users")
@Validated  // クラスレベルで@Validatedを付与
public class UserSearchController {

    private final UserService userService;

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

    @GetMapping("/{id}")
    public ResponseEntity<UserResponse> getUser(
            @PathVariable @Positive(message = "IDは正の整数で指定してください") Long id) {
        UserResponse response = userService.getUser(id);
        return ResponseEntity.ok(response);
    }

    @GetMapping
    public ResponseEntity<List<UserResponse>> searchUsers(
            @RequestParam(defaultValue = "0") @Min(value = 0, message = "ページ番号は0以上で指定してください") int page,
            @RequestParam(defaultValue = "20") @Min(value = 1, message = "ページサイズは1以上で指定してください")
                    @Max(value = 100, message = "ページサイズは100以下で指定してください") int size) {
        List<UserResponse> users = userService.searchUsers(page, size);
        return ResponseEntity.ok(users);
    }
}

ConstraintViolationExceptionのハンドリング

パスパラメータやクエリパラメータのバリデーションエラーはConstraintViolationExceptionとしてスローされます。グローバル例外ハンドラーにこの例外のハンドリングを追加します。

 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
package com.example.demoapi.exception;

import com.example.demoapi.dto.ErrorResponse;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.ConstraintViolationException;

import org.springframework.http.HttpStatus;
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 java.util.List;

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ErrorResponse> handleValidationException(
            MethodArgumentNotValidException ex,
            HttpServletRequest request) {

        List<ErrorResponse.FieldError> fieldErrors = ex.getBindingResult()
                .getFieldErrors()
                .stream()
                .map(error -> new ErrorResponse.FieldError(
                        error.getField(),
                        error.getDefaultMessage(),
                        error.getRejectedValue()))
                .toList();

        ErrorResponse response = new ErrorResponse();
        response.setStatus(HttpStatus.BAD_REQUEST.value());
        response.setError("Validation Failed");
        response.setMessage("入力値に誤りがあります");
        response.setPath(request.getRequestURI());
        response.setFieldErrors(fieldErrors);

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

    @ExceptionHandler(ConstraintViolationException.class)
    public ResponseEntity<ErrorResponse> handleConstraintViolationException(
            ConstraintViolationException ex,
            HttpServletRequest request) {

        List<ErrorResponse.FieldError> fieldErrors = ex.getConstraintViolations()
                .stream()
                .map(violation -> {
                    String propertyPath = violation.getPropertyPath().toString();
                    // メソッド名を除去してパラメータ名のみを取得
                    String field = propertyPath.contains(".")
                            ? propertyPath.substring(propertyPath.lastIndexOf(".") + 1)
                            : propertyPath;
                    return new ErrorResponse.FieldError(
                            field,
                            violation.getMessage(),
                            violation.getInvalidValue());
                })
                .toList();

        ErrorResponse response = new ErrorResponse();
        response.setStatus(HttpStatus.BAD_REQUEST.value());
        response.setError("Validation Failed");
        response.setMessage("パラメータに誤りがあります");
        response.setPath(request.getRequestURI());
        response.setFieldErrors(fieldErrors);

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

バリデーション処理の流れ

以下のシーケンス図は、Spring Boot REST APIにおけるバリデーション処理の流れを示しています。

sequenceDiagram
    participant Client as クライアント
    participant Filter as フィルター
    participant Controller as コントローラー
    participant Validator as Validator
    participant Handler as 例外ハンドラー
    participant Service as サービス

    Client->>Filter: HTTPリクエスト
    Filter->>Controller: リクエスト転送
    Controller->>Validator: @Valid/@Validated による検証
    
    alt バリデーション成功
        Validator-->>Controller: 検証OK
        Controller->>Service: ビジネスロジック実行
        Service-->>Controller: 処理結果
        Controller-->>Client: 200 OK / 201 Created
    else バリデーション失敗
        Validator-->>Controller: MethodArgumentNotValidException
        Controller->>Handler: 例外をスロー
        Handler-->>Client: 400 Bad Request + エラー詳細
    end

まとめ

本記事では、Spring Boot REST APIにおけるBean Validationの活用方法を解説しました。重要なポイントを振り返ります。

  1. 標準アノテーションの活用: @NotNull@Size@Emailなどの標準アノテーションで多くの検証要件をカバーできます
  2. カスタムバリデーション: 標準アノテーションで対応できない場合は、独自のアノテーションとバリデータを作成します
  3. グループバリデーション: 操作(登録・更新など)ごとに異なる検証ルールを適用できます
  4. 適切なエラーハンドリング: @RestControllerAdviceを使って統一されたエラーレスポンスを返却します

Bean Validationを適切に活用することで、堅牢で保守性の高いREST APIを構築できます。次のステップとして、バリデーションエラーのテスト方法や、非同期バリデーションの実装について学習することをお勧めします。

参考リンク