spring boot で request を validation したいときのあれこれ

検証環境
spring boot 3.4.4
java 21
maven 4

validation path param
path param の検証結果を載せるが query param に関しても同じ結果になる。
エラーが発生するリクエスト。
GET http://localhost:8080/api/users/4
validation ルールのアノテーションを使用
@RestController
@RequestMapping("/users")
@RequiredArgsConstructor
public class UserController {
private final UserService userService;
@GetMapping(value = "/{id}")
public ResponseEntity<UserResponse> findById(
@PathVariable @Pattern(regexp = "^[123]") String id) {
return ResponseEntity.ok(userService.findById(id));
}
}
org.springframework.web.method.annotation.HandlerMethodValidationException: 400 BAD_REQUEST
valid annotation を引数に追加
@RestController
@RequestMapping("/users")
@RequiredArgsConstructor
public class UserController {
private final UserService userService;
@GetMapping(value = "/{id}")
public ResponseEntity<UserResponse> findById(
@PathVariable @Valid @Pattern(regexp = "^[123]") String id) {
return ResponseEntity.ok(userService.findById(id));
}
}
レスポンスは変わらず。
org.springframework.web.method.annotation.HandlerMethodValidationException: 400 BAD_REQUEST
valid annotation をクラスに追加
@RestController
@RequestMapping("/users")
@RequiredArgsConstructor
@Valid
public class UserController {
private final UserService userService;
@GetMapping(value = "/{id}")
public ResponseEntity<UserResponse> findById(
@PathVariable @Pattern(regexp = "^[123]") String id) {
return ResponseEntity.ok(userService.findById(id));
}
}
レスポンスは変わらず。
org.springframework.web.method.annotation.HandlerMethodValidationException: 400 BAD_REQUEST
validated annotation を引数に追加
@RestController
@RequestMapping("/users")
@RequiredArgsConstructor
public class UserController {
private final UserService userService;
@GetMapping(value = "/{id}")
public ResponseEntity<UserResponse> findById(
@PathVariable @Validated @Pattern(regexp = "^[123]") String id) {
return ResponseEntity.ok(userService.findById(id));
}
@GetMapping
public ResponseEntity<UserResponse> searchByUserName(
@RequestParam(name = "userName") @Pattern(regexp = "^(taro)") String userName) {
return ResponseEntity.ok(userService.searchByUsername(userName));
}
}
レスポンスは変わらず。
org.springframework.web.method.annotation.HandlerMethodValidationException: 400 BAD_REQUEST
validated annotation をクラスに追加
@RestController
@RequestMapping("/users")
@RequiredArgsConstructor
@Validated
public class UserController {
private final UserService userService;
@GetMapping(value = "/{id}")
public ResponseEntity<UserResponse> findById(
@PathVariable @Pattern(regexp = "^[123]", message = "pattern が違う。") String id) {
return ResponseEntity.ok(userService.findById(id));
}
}
ConstraintViolationException がスローされメッセージが設定可能。設定してない場合はデフォルトのメッセージがでる。
これパターン以外でも message を追加したがレスポンスには出なかった。
"status": 500,
"error": "Internal Server Error",
"trace": "jakarta.validation.ConstraintViolationException: findById.id: pattern が違う
"message": "findById.id: pattern が違う。"
結果
クラスに validated アノテーションをつけると ConstraintViolationException がスローされメッセージが設定可能。ただ、ステータス500のため400にしたい場合はハンドリングが必要になる。
それ以外は HandlerMethodValidationException でステータス400になる。

validation request body
request class
public class UserRequest {
@NotNull(message = "Invalid first name.")
private String firstName;
@NotNull private String lastName;
@NotNull
@Range(min = 0, max = 100)
private int age;
@NotNull
@Pattern(regexp = "(men|female)")
private String gender;
}
valid も validated もつけない
@RestController
@RequestMapping("/users")
@RequiredArgsConstructor
@Valid
public class UserController {
private final UserService userService;
@PostMapping
public ResponseEntity<UserResponse> registerUser(@RequestBody UserRequest request) {
return ResponseEntity.ok(userService.register(request));
}
}
validate されず、リクエスト成功した。
valid annotation を引数につける
@RestController
@RequestMapping("/users")
@RequiredArgsConstructor
@Valid
public class UserController {
private final UserService userService;
@PostMapping
public ResponseEntity<UserResponse> registerUser(@RequestBody @Valid UserRequest request) {
return ResponseEntity.ok(userService.register(request));
}
}
validate され設定したメッセージも表示された。
"status": 400,
"error": "Bad Request",
"trace": "org.springframework.web.bind.MethodArgumentNotValidException
"defaultMessage": "Invalid first name.",
valid annotation をクラスにつける
@RestController
@RequestMapping("/users")
@RequiredArgsConstructor
@Valid
public class UserController {
private final UserService userService;
@PostMapping
public ResponseEntity<UserResponse> registerUser(@RequestBody UserRequest request) {
return ResponseEntity.ok(userService.register(request));
}
}
validate されず、リクエスト成功した。
validated annotation を引数につける
@RestController
@RequestMapping("/users")
@RequiredArgsConstructor
public class UserController {
private final UserService userService;
@PostMapping
public ResponseEntity<UserResponse> registerUser(@RequestBody @Validated UserRequest request) {
return ResponseEntity.ok(userService.register(request));
}
}
validate され設定したメッセージも表示された。
"status": 400,
"error": "Bad Request",
"trace": "org.springframework.web.bind.MethodArgumentNotValidException
"defaultMessage": "Invalid first name."
validated annotation をクラスにつける
@RestController
@RequestMapping("/users")
@RequiredArgsConstructor
@Validated
public class UserControl {
private final UserService userService;
@PostMapping
public ResponseEntity<UserResponse> registerUser(@RequestBody UserRequest request) {
return ResponseEntity.ok(userService.register(request));
}
}
validate されず、リクエスト成功した。
結果
引数に valid もじくは validated アノテーションをつけると MethodArgumentNotValidException がスローされ、メッセージも表示される。

validate 方法の結論
path param, query param の validate
⇒ クラスに validated アノテーションをつける。
body の validate
⇒ 引数に valid アノテーションをつける。valid でも validated でも同じ結果だったが、validated はグループを指定でき使用するvalidを任意に出来るみたいだが、それをさせたくないため。request クラスはリクエストごとにクラスを作るほうがいいと思う。

error handling
エラーハンドリングは ResponseEntityExceptionHandler を継承してハンドリングしたい例外を宣言する。ここでは ConstraintViolationException をハンドリングしてステータス400にして返せた。
body をどうするかは検討する必要があるがやりたいことはできた。
@Slf4j
@RestControllerAdvice
public class MyExceptionHandler extends ResponseEntityExceptionHandler {
private static final HttpHeaders HEADERS = new HttpHeaders();
@ExceptionHandler(ConstraintViolationException.class)
public ResponseEntity<Object> handleConstraintViolation(
ConstraintViolationException ex, WebRequest request) {
log.warn("validation error.", ex.toString());
String message =
ex.getConstraintViolations().stream()
.map(ConstraintViolation::getMessage)
.collect(Collectors.joining(System.lineSeparator()));
return handleExceptionInternal(ex, message, HEADERS, HttpStatus.BAD_REQUEST, request);
}
}
HTTP/1.1 400
Content-Type: text/plain;charset=UTF-8
Content-Length: 48
Date: Wed, 23 Apr 2025 23:23:16 GMT
Connection: close
5 以下の値にしてください
Invalid id.

custom annotation
自前のアノテーションを生成。
@Documented
@Constraint(validatedBy = GenderValidator.class)
@Target({ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
public @interface Gender {
String message() default "Invalid gender.";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
バリデーション処理を実装。
import com.example.my_spring_boot.infrastructure.validation.customAnnotation.Gender;
import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;
public class GenderValidator implements ConstraintValidator<Gender, String> {
@Override
public boolean isValid(String gender, ConstraintValidatorContext context) {
if (gender == null) return false;
return gender.matches("(men|female)");
}
}
request に付与。
@Data
public class UserRequest {
@NotNull(message = "Invalid first name.")
private String firstName;
@NotNull private String lastName;
@NotNull
@Range(min = 0, max = 100, message = "Invalid age.")
private int age;
@Gender private String gender;
}
exception handler で MethodArgumentNotValidException をハンドリング。
ハンドリング内容は要検討だが、一旦メッセージをだすだけ。
@RestControllerAdvice
public class MyExceptionHandler extends ResponseEntityExceptionHandler {
@Override
public ResponseEntity<Object> handleMethodArgumentNotValid(
MethodArgumentNotValidException ex,
HttpHeaders headers,
HttpStatusCode status,
WebRequest request) {
String message =
ex.getBindingResult().getAllErrors().stream()
.map(error -> error.getDefaultMessage())
.collect(Collectors.joining(System.lineSeparator()));
return handleExceptionInternal(ex, message, headers, status, request);
}
}
レスポンス
HTTP/1.1 400
Content-Type: text/plain;charset=UTF-8
Content-Length: 15
Invalid gender.

もう少しちゃんとエラーハンドリング
@Data
public class ErrorResponse {
private final HttpStatus status;
private final String message;
private final List<Map<String, String>> errors;
}
@Override
public ResponseEntity<Object> handleMethodArgumentNotValid(
MethodArgumentNotValidException ex,
HttpHeaders headers,
HttpStatusCode status,
WebRequest request) {
var errors =
ex.getFieldErrors().stream()
.map(error -> Map.of("field", error.getField(), "message", error.getDefaultMessage()))
.toList();
ErrorResponse errorResponse =
new ErrorResponse(HttpStatus.BAD_REQUEST, "Validation error.", errors);
return handleExceptionInternal(ex, errorResponse, headers, status, request);
}
{
"status": "BAD_REQUEST",
"message": "Validation error.",
"errors": [
{
"field": "age",
"message": "0 から 100 の間にしてください"
},
{
"field": "gender",
"message": "Invalid gender."
},
{
"field": "firstName",
"message": "null は許可されていません"
}
]
}

custom annotation の単体テスト
テストパターンをいくつか用意してそれぞれの結果と invalid だった時のメッセージのテストなどやればとりあえずはいいかな。
テスト用のDTOは record を用いると簡単でいいかも。
package com.example.my_spring_boot.ut.validation;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.params.provider.Arguments.arguments;
import com.example.my_spring_boot.infrastructure.validation.GenderValidator;
import com.example.my_spring_boot.infrastructure.validation.customAnnotation.Gender;
import jakarta.validation.ConstraintViolation;
import jakarta.validation.Validator;
import java.util.Set;
import java.util.stream.Stream;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
import org.junit.jupiter.params.provider.MethodSource;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.validation.ValidationAutoConfiguration;
import org.springframework.boot.test.context.SpringBootTest;
@SpringBootTest(classes = {ValidationAutoConfiguration.class})
public class GenderTest {
@Autowired private Validator validator;
public record GenderDto(@Gender String gender) {}
@ParameterizedTest
@MethodSource("genderProvider")
void testGenderValidator(String gender, boolean expected) {
GenderValidator validator = new GenderValidator();
assertThat(validator.isValid(gender, null)).isEqualTo(expected);
}
static Stream<Arguments> genderProvider() {
return Stream.of(
arguments("men", true),
arguments("female", true),
arguments("dansei", false),
arguments("josei", false));
}
@Test
void whenInvalidGender_thenValidationFails() {
GenderDto dto = new GenderDto("dansei");
Set<ConstraintViolation<GenderDto>> violations = validator.validate(dto);
assertThat(violations.size()).isEqualTo(1);
assertThat(violations.iterator().next().getMessage()).isEqualTo("Should be men or female.");
}
}