#Spring Boot バリデーション実践入門
#このスライドについて
#対象者
- 基本的な Spring Boot アプリを作ったことはあるが、バリデーションを体系的に学んでいない人
- Spring Boot プロジェクトの基礎設計をする人
#目標
Spring Boot のバリデーションで、「こんなことができるんだ」という事を知る
#なぜバリデーションが必要か?
- バリデーションは「クライアントが『契約』を守っているかを確認する」行為
- バリデーションを行い、不正な状態を Service に渡さないようにすることで、Controller 以降がビジネスロジックに集中できる
「Spring Boot には、このバリデーションを簡単かつ統一的に実装する仕組みが存在しているため、積極的に使っていきましょう」という話をしていきます。
#基本のバリデーション
基本は単項目チェック。 アノテーションをつけることにより、どんなバリデーションを行うか定義する。
- @RequestParam に直接定義
- @RequestBody のバリデーション定義(Bean のフィールドにバリデーション定義)
- デフォルトアノテーションの一例
#@RequestParam に直接定義
次のように、引数にアノテーションをつけることでバリデーションを定義する。
package dev.mikoto2000.springboot.validation.workshop.firststep.controller;
import org.hibernate.validator.constraints.Length;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import jakarta.validation.constraints.Pattern;
/**
* EchoController
*/
@RestController
public class EchoController {
/**
* 引数を直接バリデーション。
*
* @param message 文字列
* - 必須ではない
* - デフォルト値は "0"
* - 1文字以上10文字以下
* - 数字のみ
* @return 引数に改行文字を付与して返す
*/
@GetMapping("/number-echo")
public String numberEcho(
@RequestParam(required = false, defaultValue = "0")
// 文字列の長さは 1 から 10 とする
@Length(min = 1, max = 10)
// 文字は、正規表現 `[0-9]*` にマッチする
@Pattern(regexp = "[0-9]*")
String message) {
return message + "\n";
}
}#動作確認
#バリデーション成功
次のように、「正規表現 [0-9]* に適合」したリクエストを送った場合、 200 レスポンスが返ってきて、message に改行を加えたレスポンスが返ってくる。
vscode ➜ /workspaces/spring-boot-validation-workshop (main) $ curl -v localhost:8080/number-echo?message=123 # 正規表現 `[0-9]*` に適合
* Trying 127.0.0.1:8080...
* Connected to localhost (127.0.0.1) port 8080 (#0)
> GET /number-echo?message=123 HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/7.88.1
> Accept: */*
>
< HTTP/1.1 200
< Content-Type: text/plain;charset=UTF-8
< Content-Length: 4
< Date: Fri, 02 Jan 2026 02:10:05 GMT
<
123
* Connection #0 to host localhost left intact#バリデーション失敗
次のように、「正規表現 [0-9]* に適合」していないリクエストを送った場合、 400 レスポンスが返ってきて、ずらずらとエラーメッセージが表示される。
vscode ➜ /workspaces/spring-boot-validation-workshop (main) $ curl -v localhost:8080/number-echo?message=123
* Trying 127.0.0.1:8080...
* Connected to localhost (127.0.0.1) port 8080 (#0)
> GET /number-echo?message=123 HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/7.88.1
> Accept: */*
>
< HTTP/1.1 200
< Content-Type: text/plain;charset=UTF-8
< Content-Length: 4
< Date: Fri, 02 Jan 2026 02:10:05 GMT
<
123
* Connection #0 to host localhost left intact
vscode ➜ /workspaces/spring-boot-validation-workshop (main) $ curl -v localhost:8080/number-echo?message=123a
* Trying 127.0.0.1:8080...
* Connected to localhost (127.0.0.1) port 8080 (#0)
> GET /number-echo?message=123a HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/7.88.1
> Accept: */*
>
< HTTP/1.1 400
< Content-Type: application/json
< Transfer-Encoding: chunked
< Date: Fri, 02 Jan 2026 02:12:18 GMT
< Connection: close
<
{"timestamp":"2026-01-02T02:12:18.157+00:00","status":400,"error":"Bad Request","trace":"org.springframework.web.method.annotation.HandlerMethodValidationException: 400 BAD_REQUEST \"Valida
tion failure\"\n\tat org.springframework.web.method.annotation.HandlerMethodValidator.applyArgumentValidation(HandlerMethodValidator.java:106)\n\tat org.springframework.web.method.support.InvocableHandlerMethod.invokeForRequest(InvocableHandlerMethod.java:187)\n\tat org.springframework.web.servlet.mvc.method.annotation.ServletInvocableHandlerMethod.invokeAndHandle(ServletInvocableHandlerMethod.java:118)\n\tat org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter.invokeHandlerMethod(RequestMappingHandlerAdapter.java:991)\n\tat org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter.handleInternal(RequestMappingHandlerAdapter.java:896)\n\tat org.springframework.web.servlet.mvc.method.AbstractHandlerMethodAdapter.handle(AbstractHandlerMethodAdapter.java:87)\n\tat org.springframework.web.servlet.DispatcherServlet.doDispatch(DispatcherServlet.java:1089)\n\tat org.springframework.web.servlet.DispatcherServlet.doService(DispatcherServlet.java:979)\n\tat org.springframework.web.servlet.FrameworkServlet.processRequest(FrameworkServlet.java:1014)\n\tat org.springframework.web.servlet.FrameworkServlet.doGet(FrameworkServlet.java:903)\n\tat jakarta.servlet.http.HttpServlet.service(HttpServlet.java:564)\n\tat org.springframework.web.servlet.FrameworkServlet.service(FrameworkServlet.java:885)\n\tat jakarta.servlet.http.HttpServlet.service(HttpServlet.java:658)\n\tat org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:195)\n\tat org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:140)\n\tat org.apache.tomcat.websocket.server.WsFilter.doFilter(WsFilter.java:51)\n\tat org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:164)\n\tat org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:140)\n\tat org.springframework.web.filter.RequestContextFilter.doFilterInternal(RequestContextFilter.java:100)\n\tat org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116)\n\tat org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:164)\n\tat org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:140)\n\tat org.springframework.web.filter.FormContentFilter.doFilterInternal(FormContentFilter.java:93)\n\tat org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116)\n\tat org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:164)\n\tat org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:140)\n\tat org.springframework.web.filter.CharacterEncodingFilter.doFilterInternal(CharacterEncodingFilter.java:201)\n\tat org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116)\n\tat org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:164)\n\tat org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:140)\n\tat org.apache.catalina.core.StandardWrapperValve.invoke(StandardWrapperValve.java:167)\n\tat org. apache.catalina.core.StandardContextValve.invoke(StandardContextValve.java:90)\n\tat org.apache.catalina.authenticator.AuthenticatorBase.invoke(AuthenticatorBase.java:483)\n\tat org.apache. catalina.core.StandardHostValve.invoke(StandardHostValve.java:116)\n\tat org.apache.catalina.valves.ErrorReportValve.invoke(ErrorReportValve.java:93)\n\tat org.apache.catalina.core.StandardEngineValve.invoke(StandardEngineValve.java:74)\n\tat org.apache.catalina.connector.CoyoteAdapter.service(CoyoteAdapter.java:344)\n\tat org.apache.coyote.http11.Http11Processor.service(Http11Processor.java:398)\n\tat org.apache.coyote.AbstractProcessorLight.process(AbstractProcessorLight.java:63)\n\tat org.apache.coyote.AbstractProtocol$ConnectionHandler.process(AbstractProtocol.java:903)\n\tat org.apache.tomcat.util.net.NioEndpoint$SocketProcessor.doRun(NioEndpoint.java:1769)\n\tat org.apache.tomcat.util.net.SocketProcessorBase.run(SocketProcessorBase.java:52)\n\tat org.apache.tomcat.util.threads.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1189)\n\tat org.apache.tomcat.util.threads.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:658)\n\tat org.apache.tomcat.util.threads.TaskThread$WrappingRunnable.run(TaskThread.java:63)\n\tat java.base/java.lang.Thread.run(Thread.java:1583)\n","message":"Validation failed for method='public java.lang.String dev.mikoto2000.springboot.validation.workshop.firststep.controller.EchoController.numberEcho(java.lang.String)'. Error count: 1","errors":[{"codes":["Pattern.echoController#numberEcho.message","Pattern.message","Pattern.java.lang.String","Pattern"],"defaultMessage":"must match \"[0-9]*\"","arguments":[{"codes":["echoController#numberEcho.message","message"],"arguments":null,"defaultMessage":"message","code":"message"},[],{"codes":["[0-9]*"],"defaultMessage":"[0-9]*","arguments":null}]}],"path":"/number-echo"}vscode ➜ /workspaces/spring-boot-validation
* Closing connection 0#@RequestBody のバリデーション定義
次のように、 @RequestBody に @Valid を付与することで、その Bean のバリデーション定義を検証する。
そして、Bean のフィールドに、バリデーション定義を付与する。
BeanEchoController.java:
package dev.mikoto2000.springboot.validation.workshop.firststep.controller;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import dev.mikoto2000.springboot.validation.workshop.firststep.bean.ValidationExampleBean;
import jakarta.validation.Valid;
/**
* BeanEchoController
*/
@RestController
public class BeanEchoController {
/**
* Bean 定義でバリデーション。
*
* @param bean リクエストボディの JSON をマッピングした Bean
* - 各フィールドは null 不可(ValidationExampleBean の定義を参照)
* @return 引数を Lombok の toString() で文字列化して改行文字を付与して返す
*/
@PostMapping("/bean-echo")
public String beanEcho(
@Valid @RequestBody ValidationExampleBean bean) {
return bean.toString() + "\n";
}
}ValidationExampleBean.java:
package dev.mikoto2000.springboot.validation.workshop.firststep.bean;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
/**
* ValidationExampleBean
*/
@Data
public class ValidationExampleBean {
@NotNull
private String stringValue;
@NotNull
private Integer integerValue;
@NotNull
private Double doubleValue;
}#カスタムバリデーション
基本のバリデーションで足りない場合、ロジックを自分で書くことになる。 相関チェックや、単項目チェックでもデフォルトで足りないものなど。
- 相関チェック(クラスレベル制約)
- カスタムバリデーターの作成
#相関チェック(クラスレベル制約)
AssertTrue アノテーションを使用することで、相関チェックも可能。
isXxx メソッドに、 @AssertTrue を付与し、バリデーション結果を true/false で返却する。
package dev.mikoto2000.springboot.validation.workshop.firststep.bean;
import jakarta.validation.constraints.AssertTrue;
import jakarta.validation.constraints.NotNull;
import lombok.AllArgsConstructor;
import lombok.Data;
/**
* CalcInfo
*/
@AllArgsConstructor
@Data
public class CalcInfo {
@NotNull
private Double left;
@NotNull
private Double right;
@NotNull
private Operation operation;
public enum Operation {
ADD,
SUBTRACT,
MULTIPLY,
DIVIDE
}
/**
* 割り算はゼロ除算してはいけない。
*/
@AssertTrue(message = "Division by zero is not allowed.")
public boolean isValid() {
// 割り算、かつ、右辺がゼロの場合は不正とする
if (operation == Operation.DIVIDE && right == 0) {
return false;
}
return true;
}
}#カスタムバリデーター
デフォルトで提供されているアノテーションでは不十分な場合、自分でアノテーションを作ることもできる。
次の例では Range を拡張し、範囲プラスαの数値を Valid とするアノテーションを使っている。
package dev.mikoto2000.springboot.validation.workshop.firststep.bean;
import dev.mikoto2000.springboot.validation.workshop.firststep.validation.constraints.RangeEx;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
/**
* BatteryStatus
*/
@Data
public class BatteryStatus {
// バッテリー残量(0〜100の範囲, ただし、255 は不明とする)
// 無理矢理感あるが、 IoT 機器とかの API でたまにこういうのがある
@NotNull
@RangeEx(min = 0, max = 100, others = {255})
private Integer batteryLevel;
// 充電中かどうか
@NotNull
private Boolean charging;
// バッテリーの健康状態
@NotNull
private Health health;
public static enum Health {
GOOD,
FAIR,
POOR
}
}#カスタムバリデーターの作成
「Bean に付ける制約を表すクラス」と「実際にバリデーションの処理を行うクラス」を作成することで、先ほどのような独自バリデーションを行える。
#Bean に付ける制約を表すクラスを作成
RangeEx.java:
package dev.mikoto2000.springboot.validation.workshop.firststep.validation.constraints;
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Repeatable;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import jakarta.validation.Constraint;
import jakarta.validation.OverridesAttribute;
import jakarta.validation.Payload;
import jakarta.validation.ReportAsSingleViolation;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import org.hibernate.validator.constraints.Range;
import dev.mikoto2000.springboot.validation.workshop.firststep.validation.validator.RangeExValidator;
/**
* RangeEx。
*/
@Target({
ElementType.FIELD,
ElementType.METHOD,
ElementType.PARAMETER,
ElementType.ANNOTATION_TYPE,
ElementType.TYPE_USE })
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = RangeExValidator.class)
@Documented
@Repeatable(RangeEx.List.class)
@Range
@ReportAsSingleViolation
public @interface RangeEx {
/**
* 最小値。
*/
@OverridesAttribute(constraint = Min.class, name = "value")
long min() default 0;
/**
* 最大値。
*/
@OverridesAttribute(constraint = Max.class, name = "value")
long max() default Integer.MAX_VALUE;
String message() default "{dev.mikoto2000.springboot.validation.workshop.firststep.validation.constraints.RangeEx.message}";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
/**
* 最大値と最小値範囲以外で許可する値のリスト。
*/
long[] others();
@Target({
ElementType.FIELD,
ElementType.METHOD,
ElementType.PARAMETER,
ElementType.ANNOTATION_TYPE })
@Retention(RetentionPolicy.RUNTIME)
@Documented
@interface List {
RangeEx[] value();
}
}#実際にバリデーションの処理を行うクラス作成
RangeExValidator.java:
package dev.mikoto2000.springboot.validation.workshop.firststep.validation.validator;
import java.util.Arrays;
import dev.mikoto2000.springboot.validation.workshop.firststep.validation.constraints.RangeEx;
import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;
/**
* RangeExValidator
*/
public class RangeExValidator implements ConstraintValidator<RangeEx, Number> {
private long min;
private long max;
private long[] others;
@Override
public void initialize(RangeEx rangeEx) {
this.min = rangeEx.min();
this.max = rangeEx.max();
this.others = rangeEx.others();
}
@Override
public boolean isValid(Number value, ConstraintValidatorContext context) {
// others に存在しているか確認
if (Arrays.stream(others).anyMatch(e -> e == value.longValue())) {
// others に存在しているものは OK
return true;
} else {
// others に存在していない場合、 min, max の範囲チェックを行う
if (min <= value.longValue() && value.longValue() <= max) {
// 範囲内であれば OK
return true;
} else {
// 範囲外であれば NG
return false;
}
}
}
}#エラーメッセージと i18n
エラーメッセージの設定方法は、大きく分けて以下 2 つ。
- 個別指定(アノテーションで指定)
- 言語別デフォルト指定
#個別指定(アノテーションで指定)
次のように、バリデーション定義のアノテーションに message プロパティを追加することで、個別にエラーメッセージを指定できる。
package dev.mikoto2000.springboot.validation.workshop.firststep.controller;
import org.hibernate.validator.constraints.Length;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import jakarta.validation.constraints.Pattern;
/**
* EchoController
*/
@RestController
public class EchoController {
/**
* 引数を直接バリデーション。
*
* @param message 文字列
* - 必須ではない
* - デフォルト値は "0"
* - 1文字以上10文字以下
* - 数字のみ
* @return 引数に改行文字を付与して返す
*/
@GetMapping("/number-echo")
public String numberEcho(
@RequestParam(required = false, defaultValue = "0")
// 文字列の長さは 1 から 10 とする
@Length(min = 1, max = 10, message = "1文字以上10文字以下で指定してください")
// 文字は、正規表現 `[0-9]*` にマッチする
@Pattern(regexp = "[0-9]*", message = "数字のみで指定してください")
String message) {
return message + "\n";
}
}#デフォルト指定
src/main/resources/ValidationMessages.properties にデフォルトメッセージ定義を記述できる。
jakarta.validation.constraints.AssertFalse.message=不正な値が入力されました。
jakarta.validation.constraints.AssertTrue.message=s不正な値が入力されました。
jakarta.validation.constraints.DecimalMax.message={value}より同じか小さい値を入力してください。
jakarta.validation.constraints.DecimalMin.message={value}より同じか大きい値を入力してください。
jakarta.validation.constraints.Digits.message=整数{integer}桁以内、小数{fraction}桁以内で入力してください。
jakarta.validation.constraints.Future.message=未来の日付を入力してください。
jakarta.validation.constraints.Max.message={value}以下の値を入力してください。
jakarta.validation.constraints.Min.message={value}以上の値を入力してください。
jakarta.validation.constraints.NotNull.message=値を入力してください。
jakarta.validation.constraints.Null.message=未入力でなければいけません。
jakarta.validation.constraints.Past.message=過去の日付を入力してください。
jakarta.validation.constraints.Pattern.message="{regexp}"にマッチしていません。
jakarta.validation.constraints.Size.message={min}から{max}の間の値を入力してください。
org.hibernate.validator.constraints.Email.message=E-mail形式で入力してください。
org.hibernate.validator.constraints.Length.message={min}文字から{max}文字の間で入力してください。
org.hibernate.validator.constraints.NotEmpty.message=値を入力してください。
org.hibernate.validator.constraints.Range.message={min}から{max}の間の値を入力してください。
org.hibernate.validator.constraints.URL.message=不正なURLです。
dev.mikoto2000.springboot.validation.workshop.firststep.validation.constraints.RangeEx.message={min}から{max}の間の値、または{othre}を入力してください。#言語別デフォルト指定
src/main/resources/ValidationMessages_en.properties や src/main/resources/ValidationMessages_ja.properties 等を作成することで、言語別のデフォルトメッセージ定義が可能。
出力するメッセージは、リクエストヘッダーの Locale ヘッダーを見て判断される。
#バリデーションエラーの返却
バリデーションエラーは、一元管理するのが楽なので、その方法を紹介する。
- advice によるバリデーションエラー返却の一元化
#advice によるバリデーションエラー返却の一元化
- デフォルトではスタックトレースが含まれており、これをよしとしない場合が多い
- また、バリデーションエラーの構造は全エンドポイントで統一されていることがほとんど
- ⇒
@RestControllerAdviceを用いてバリデーションエラー返却の一元化が可能
#@RestControllerAdvice の定義
@RestControllerAdvice 内に次の 3 種類のメソッドを追加することで、バリデーション関連エラーの一元化が可能。
package dev.mikoto2000.springboot.validation.workshop.firststep.controller.advice;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import org.springframework.http.HttpStatus;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.method.annotation.HandlerMethodValidationException;
import dev.mikoto2000.springboot.validation.workshop.firststep.bean.ValidationError;
import dev.mikoto2000.springboot.validation.workshop.firststep.bean.ValidationErrors;
import lombok.extern.slf4j.Slf4j;
/**
* ValidationExceptionHandler
*/
@Slf4j
@RestControllerAdvice
public class ValidationExceptionHandler {
/**
* メソッド引数の単純値のバリデーションエラーを処理するハンドラ。
* @return ValidationErrors
*/
@ExceptionHandler(HandlerMethodValidationException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ValidationErrors handleMethodValidationException(HandlerMethodValidationException e) {
log.info("handleMethodValidationException: {}", e);
List<ValidationError> errors = new ArrayList<>();
for (var error : e.getParameterValidationResults()) {
String field = error.getMethodParameter().getParameterName();
List<ValidationError> fieldErrors = error.getResolvableErrors().stream()
.map(ve -> new ValidationError(field, ve.getDefaultMessage()))
.toList();
errors.addAll(fieldErrors);
}
return new ValidationErrors(errors);
}
/**
* メソッド引数の bean のバリデーションエラーを処理するハンドラ。
* @return ValidationErrors
*/
@ExceptionHandler(MethodArgumentNotValidException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ValidationErrors handleMethodArgumentNotValidException(MethodArgumentNotValidException e) {
log.info("handleMethodArgumentNotValidException: {}", e);
List<ValidationError> errors = e.getFieldErrors().stream()
.map((error) -> new ValidationError(error.getField(), error.getDefaultMessage()))
.toList();
return new ValidationErrors(errors);
}
/**
* シンタックスエラーなど、そもそも正しくないリクエストだった場合に処理するハンドラ。
* @return ValidationErrors
*/
@ExceptionHandler(HttpMessageNotReadableException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ValidationErrors handleMethodArgumentNotValidException(HttpMessageNotReadableException e) {
log.info("handleMethodArgumentNotValidException: {}", e);
List<ValidationError> errors = List.of(new ValidationError(null, e.getLocalizedMessage()));
return new ValidationErrors(errors);
}
}#まとめ
今回は、以下の項目が行えることを説明してきました。
- 単項目バリデーション
- 相関チェックのバリデーション
- デフォルトメッセージの設定方法
- 言語別のデフォルトメッセージの設定方法
- バリデーションエラーの効率の良いハンドリング方法
Spring Boot は、効率よく望んだとおりのバリデーションを行うための仕組みを用意してくれています。 これら機能を活用してどんどん楽をしていきましょう。