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.

Architecture Pony
Architecture Pony
Architecture Pony
Spring Boot Validation Deep Dive: @Valid vs @Validated, Global Exception Handling & Unified Error Contracts

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 coverage

Learning 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 给排查
Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

Bean ValidationvalidationSpring BootHibernate ValidatorGlobal Exception Handling@Validated@ValidError Response
Architecture Pony
Written by

Architecture Pony

Learning notes

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.