Spring Boot Validation Deep Dive: @Valid vs @Validated, Global Exception Handling & Unified Error Contracts
This article dissects Spring Boot's validation system, explaining the dual validation paths for @RequestBody and @RequestParam, the critical differences between @Valid and @Validated, core source code mechanisms, group validation, custom constraints, and a complete global exception handling strategy with a unified error response contract.
Spring Boot validation sits between HTTP requests and business logic as the first line of defense. Failures here cause dirty data persistence, stack trace leaks, and inconsistent error formats that burden frontend integration.
Three Typical Failure Scenes
Dirty data persistence : Frontend bypasses UI, sends amount = -1, inventory becomes negative. Without validation, business rule interpretation is delegated to callers.
Stack trace leakage : NPE returns full stack trace exposing class names, packages, SQL fragments — a security issue and leaves clients unable to decide retry vs report.
Inconsistent error formats : Same parameter error returns Spring's default timestamp/status/errors object, a simple {"msg":"fail"}, or a 500 with stack trace. Frontend must write numerous if-else fallbacks.
Expected unified contract (machine-readable + human-readable):
{ "code": "PARAM_INVALID", "message": "参数不合法", "details": [ {"field": "phone", "msg": "手机号格式不正确"} ] }Reality: three interfaces, three error shapes.
Validation System Architecture: Three Layers
Spring Boot validation = Bean Validation (jakarta.validation) defines annotations/API + Hibernate Validator default implementation + Spring's LocalValidatorFactoryBean bridges implementation into container as Spring Validator.
Single starter dependency:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency>@Valid vs @Validated: Most Confused Pair
@Valid : JSR standard, placed on parameter/field, supports cascaded validation (nested objects), no grouping support.
@Validated : Spring extension, superset of @Valid — adds value() for groups ; placed on class enables method-level validation via AOP.
Mnemonic: Nested use @Valid, groups and class-level use @Validated . Both can appear on same parameter.
Core Flow: Two Distinct Validation Paths
Different parameter shapes take completely different validation routes — root cause of "validation not working":
@RequestBody Path (Parameter Resolver)
DispatcherServlet selects RequestResponseBodyMethodProcessor
JSON deserialized → DTO object
validateIfApplicable(): triggers only if parameter has @Valid/@Validated
Failure → BindingResult records errors → throws MethodArgumentNotValidException
Caught by @RestControllerAdvice → converted to unified error response
@RequestParam Path (AOP Interceptor)
Class has @Validated → generates AOP proxy
Before method invocation, MethodValidationInterceptor validates each parameter's constraint annotations
Failure → throws ConstraintViolationException (Boot 3.2+ some scenarios throw HandlerMethodValidationException)
Key difference : @RequestBody validated by parameter resolver (needs @Valid on parameter); @RequestParam validated by AOP interceptor (needs @Validated on class). Memorizing only one style guarantees half scenarios fail.
Core Source Code: Five Decisive Classes
1. validateIfApplicable: Why Validation "Sometimes Works"
// RequestResponseBodyMethodProcessor protected void validateIfApplicable( WebDataBinder binder, MethodParameter param) { Annotation[] annos = param.getParameterAnnotations(); for (Annotation ann : annos) { Validated validatedAnn = AnnotationUtils.getAnnotation(ann, Validated.class); if (validatedAnn != null || ann.annotationType().getSimpleName().startsWith("Valid")) { Object hints = (validatedAnn != null ? validatedAnn.value() : new Object[0]); binder.validate(hints); break; } } }Explains three behaviors: @Validated prioritized over @Valid (checked first); any annotation starting with "Valid" triggers validation (custom @ValidXxx works); @Validated's value() passed as group hints.
2. MethodValidationInterceptor: Class-Level Validation AOP Entry
// MethodValidationInterceptor (simplified) public Object invoke(MethodInvocation inv) throws Throwable { Set<ConstraintViolation<Object>> rs = executableValidator.validateParameters( inv.getThis(), inv.getMethod(), inv.getArguments(), groups); if (!rs.isEmpty()) { throw new ConstraintViolationException(rs); } return inv.proceed(); }Pre-invocation interception: method body never executes. Class-level @Validated wraps Service in extra proxy — consider when analyzing @Transactional propagation.
3. ExceptionHandlerMethodResolver: How Advice Picks Handler
// ExceptionHandlerMethodResolver (simplified) public Method resolveMethodByThrowable(Throwable t) { Throwable cause = t; while (cause != null) { Method m = mappedMethods.get(cause.getClass()); if (m != null) return m; cause = cause.getCause(); } return null; }Essence of exact-match priority : traverses exception → parent → grandparent. MethodArgumentNotValidException and Exception both match, former has closer inheritance distance, always wins first. Fallback @ExceptionHandler(Exception.class) safe, won't mistakenly intercept.
4. SpringValidatorAdapter: The Bridge
// SpringValidatorAdapter: a bridge // Left: Spring Validator interface // Right: Hibernate Validator implementation public class SpringValidatorAdapter implements SmartValidator { private final Validator targetValidator; // WebDataBinder.validate() delegates entirely to JSR Validator: // constraints, groups, cascading execute there, // results wrapped back into Spring Errors }All validation logic (constraints, groups, cascading) runs in Hibernate Validator; Spring only wraps results.
5. BindingResult: Error Collector
// BindingResult: error collector // Results stored here first, not thrown immediately public interface BindingResult extends Errors { List<ObjectError> getAllErrors(); // Classic usage: @Valid param followed by BindingResult, // errors don't throw, handled manually // public OrderVO create( // @Valid OrderCreateDTO dto, // BindingResult result) { ... } }Production advice : Don't manually handle BindingResult in Controller — creates boilerplate per endpoint. Let exceptions propagate, centralize in @RestControllerAdvice.
Validation Strategies: From Field Annotations to Groups
Field Annotations: DTO as Written Contract
public class OrderCreateDTO { @NotBlank(message = "收货人不能为空") private String receiver; @Pattern(regexp = "^1\d{10}$", message = "手机号格式不正确") private String phone; @NotNull @Min(value = 1, message = "至少买一件") private Integer quantity; @Valid @NotNull(message = "地址不能为空") private AddressDTO address; }Three easily confused annotations : @NotNull (non-null), @NotBlank (non-null & trimmed non-empty, strings only), @NotEmpty (collection/string non-empty). Empty string "": @NotNull passes, @NotBlank blocks, @NotEmpty blocks.
Group Validation: Same DTO, Two Rule Sets
Create requires id absent, update requires id present — not a reason for two DTOs, groups' core job:
public interface Create {} public interface Update {} @NotBlank(groups = {Create.class, Update.class}) private String receiver; @Null(groups = Create.class) @NotNull(groups = Update.class) private Long id; @PostMapping("/orders") OrderVO create(@RequestBody @Validated(Create.class) OrderCreateDTO dto) { ... } @PutMapping("/orders") OrderVO update(@RequestBody @Validated(Update.class) OrderCreateDTO dto) { ... }Custom Annotations: Constraint Reuse Endpoint
Phone validation appears in 10+ DTOs → encapsulate as @Phone:
@Target({ElementType.FIELD, ElementType.PARAMETER}) @Retention(RetentionPolicy.RUNTIME) @Constraint(validatedBy = PhoneValidator.class) public @interface Phone { String message() default "手机号格式不正确"; Class<?>[] groups() default {}; Class<? extends Payload>[] payload() default {}; } public class PhoneValidator implements ConstraintValidator<Phone, String> { private static final Pattern P = Pattern.compile("^1\d{10}$"); @Override public boolean isValid(String v, ConstraintValidatorContext ctx) { if (v == null) return true; return P.matcher(v).matches(); } }Convention : Custom constraints return true for null (delegate required check to @NotNull) — compositional design, single responsibility.
Global Exception Handling: One Advice Catches All
@RestControllerAdvice + @ExceptionHandler = single error exit. Responsibilities: validation failure, unreadable body, business exceptions, unknown exceptions — none missed.
@RestControllerAdvice public class GlobalExceptionHandler { @ExceptionHandler(MethodArgumentNotValidException.class) public ErrorResponse handleInvalid(MethodArgumentNotValidException e) { List<String> errs = e.getBindingResult().getFieldErrors().stream() .map(f -> f.getField() + ": " + f.getDefaultMessage()) .toList(); return ErrorResponse.of("PARAM_INVALID", "参数不合法", errs); } @ExceptionHandler(ConstraintViolationException.class) public ErrorResponse handleViolation(ConstraintViolationException e) { List<String> errs = e.getConstraintViolations().stream() .map(v -> v.getMessage()) .toList(); return ErrorResponse.of("PARAM_INVALID", "参数不合法", errs); } @ExceptionHandler(HttpMessageNotReadableException.class) public ErrorResponse handleUnreadable(HttpMessageNotReadableException e) { return ErrorResponse.of("BODY_INVALID", "请求体格式错误", List.of()); } @ExceptionHandler(BizException.class) public ErrorResponse handleBiz(BizException e) { return ErrorResponse.of(e.getCode(), e.getMessage(), List.of()); } @ExceptionHandler(Exception.class) public ErrorResponse handleOther(Exception e, HttpServletRequest req) { log.error("uncaught", e); return ErrorResponse.of("INTERNAL_ERROR", "服务开小差了,请稍后重试", List.of(traceId())); } }Two Selection Guidelines
Don't rush to extend ResponseEntityExceptionHandler . It converts standard MVC exceptions to default ResponseEntity format, which conflicts with your unified contract — introduces second error format. Either write all handlers yourself, or extend and override each.
Fallback handler mandatory . It's the final gate preventing stack trace leakage — even if only log.error + vague message + traceId.
Unified Error Response Contract
Error response serves two audiences : code for machines (frontend/gateway/monitoring logic branching), message and details for humans (direct display/tooltip).
public class ErrorResponse { private String code; // machine: error code private String message; // human: safe copy private List<String> details; private String traceId; // troubleshooting anchor private long timestamp; public static ErrorResponse of(String code, String message, List<String> details) { ... } } // Response example: HTTP 400 // { // "code": "PARAM_INVALID", // "message": "参数不合法", // "details": ["phone: 手机号格式不正确"], // "traceId": "a1b2c3d4", // "timestamp": 1762068000000 // }Error code naming: segmented naming — PARAM_INVALID / BIZ_ORDER_DUP / AUTH_EXPIRED / INTERNAL_ERROR. Prefix defines domain, suffix defines cause; monitoring dashboards aggregate by prefix for instant error distribution view.
Practice: Order Interface Three Defense Lines
Line 1: Entrance Validation (Controller)
DTO field annotations + groups block all structural illegal inputs. No DB queries, no remote calls — pure local judgment. High-failure-rate inputs stopped here.
Line 2: Business Rule Validation (Service)
Rules requiring DB checks — duplicate order, sufficient inventory, coupon expiry — expressed via business exceptions:
@Service public class OrderService { public OrderVO create(OrderCreateDTO dto) { if (orderRepository.existsOpenOrder(dto.getUserId())) { throw new BizException("BIZ_ORDER_DUP", "存在未完成订单,请先处理"); } // ... persist } }Line 3: Global Collection (Advice)
All exceptions from two lines (MethodArgumentNotValidException / BizException / unknown) converge in Advice, unified into ErrorResponse. Frontend now handles only one error format.
Before/after comparison : Error response formats converged from 3 to 1; stack trace leakage eliminated; parameter issue mean troubleshooting time reduced from "ask frontend for payload" to "traceId direct locate".
Eight High-Frequency Pitfalls & Troubleshooting Manual
@RequestParam validation ineffective : Missing @Validated on class. Uses AOP path, @Valid on parameter useless.
Nested object inner layer not validated : Forgot @Valid on outer field. @NotNull only checks reference, not inner fields.
List<DTO> not validated as whole : @Valid on List validates List itself. Element validation needs class-level @Validated + parameter constraints, or custom container wrapper.
Boot 3.2 upgrade changes exception type : Method validation some scenarios changed from ConstraintViolationException to HandlerMethodValidationException; old handler stops catching, need new handler.
Group validation with @Valid but no group : @Valid lacks value(), always validates default group only; Create group rules skipped entirely.
Message concatenates internal info : e.g., "Insufficient stock, 3 left" may expose business data. Human-facing messages need desensitization awareness.
Custom annotation missing validatedBy : No startup error, runtime validation silently skipped — hardest to debug.
Catching validation exception in Controller : Advice never receives, error format forks again. Principle: Controller only forwards, exceptions always propagate upward .
Three-Step Troubleshooting
Validation not working? Three-step locate: 1. Check path: @RequestBody or @RequestParam? Decides which annotation to use 2. Check annotations: Nested have @Valid? Method-level have class @Validated? 3. Check exception: Throws MethodArgumentNotValidException or ConstraintViolationException? Verify handler coverageLearning Path & Interview Questions
Four-Stage Path
Stage 1 (Can use) : Common constraints, @Valid cascading, unified error response. Independently complete module input validation.
Stage 2 (Understand principle) : Two validation path differences, @Validated AOP implementation, advice matching rules. Explain "why not working".
Stage 3 (Can extend) : Custom constraints, group system, class-level validation + @Transactional proxy stacking analysis.
Stage 4 (Systematic) : Error code specification, contract testing, frontend-backend error handling collaboration, gateway vs microservice validation division.
12 High-Frequency Interview Questions
@Valid vs @Validated differences? Applicable scenarios?
@NotNull, @NotBlank, @NotEmpty differences?
@RequestBody vs @RequestParam validation path differences?
Who throws MethodArgumentNotValidException, at which step?
How to trigger cascaded validation for nested objects?
How to validate List collection elements?
Group validation principle? Can @Valid use groups?
Multiple @ExceptionHandler match — how selected?
@RestControllerAdvice working principle?
How to ensure stack trace not leaked to frontend?
Error code design? Who reads code vs message?
Boot 3.2 behavior changes in method validation?
One-Page Cheat Sheet
参数校验与全局异常 · 一页速记 ────────────────────────────── 嵌套用 @Valid,分组类级 @Validated @RequestBody:解析器校验,参数标 @Valid 其他参数:AOP 校验,类上标 @Validated 嵌套字段必加 @Valid,否则内层不查 null 一律放行自定义约束,交给 @NotNull 分组:@Validated(Create.class) 指定组 HandlerMethodValidationException ← Boot 3.2 新增,handler 要补 Advice 匹配:继承距离近的精确优先 兜底 handler 必须有:log + 模糊文案 + traceId,异常栈不出网 code 给机器,message 给人,details 给字段级定位,traceId 给排查Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
