Why Your @Validated Annotations Silently Fail: Spring Boot Method Validation Proxy Pitfalls
This article dissects Spring Boot's three validation paths — request body, controller parameters, and service methods — revealing why @Validated on service methods silently fails without a proxy, and details proxy creation conditions, self-invocation traps, collection validation syntax, exception mapping, and debugging steps.
The article opens with a production incident: an @Service method annotated with @NotBlank on its parameters accepted empty strings during a two-hour load test, persisting 300+ rows with blank order_no. The annotations compiled cleanly, startup logs showed no errors, and no runtime exceptions were thrown — validation simply never executed.
Three Validation Paths in Spring MVC
The author identifies three distinct validation entry points, each with its own executor, exception type, and proxy dependency:
Request body parameters
Executor: RequestResponseBodyMethodProcessor Failure Exception: MethodArgumentNotValidException Requires Proxy: No
Simple type parameters (Spring Framework 6.1+)
Executor: HandlerMethodValidator Failure Exception: HandlerMethodValidationException Requires Proxy: No
Service methods
Executor: MethodValidationInterceptor Failure Exception: ConstraintViolationException Requires Proxy: Yes
Request-body validation runs during argument resolution: after Jackson deserialization, validateIfApplicable checks for @Valid and delegates to the validator. Simple-type parameter validation was added in Framework 6.1 via HandlerMethodValidator; previously @Min on Integer page required class-level @Validated to work. @InitBinder can register custom Validator instances that run in parallel with Bean Validation, potentially causing duplicate checks.
Service-layer validation is the only path that depends on AOP. @Validated on the class triggers MethodValidationPostProcessor (a BeanPostProcessor) to wrap the bean in a proxy. The interceptor invokes ExecutableValidator with the method signature and actual arguments, throwing ConstraintViolationException on violation.
Proxy Creation Timing and Conditions
MethodValidationPostProcessorruns after bean initialization. It matches only class-level @Validated (including inherited annotations); method-level @Validated is ignored — a mistake nearly invisible in code reviews. Spring Boot registers this post-processor in ValidationAutoConfiguration conditionally on the absence of a user-defined bean of the same type; declaring your own disables Boot's auto-configuration including proxy-type and adaptation settings. Proxy type follows spring.aop.proxy-target-class (default true, yielding CGLIB subclasses).
A subtle trap: with proxyTargetClass=false (JDK dynamic proxy), the proxy implements only interfaces. If @Validated resides solely on the implementation class, the pointcut misses it. The robust fix: annotate both the interface and the implementation.
Another prerequisite: a Bean Validation implementation (typically hibernate-validator) must be on the classpath. Since Boot 2.3, spring-boot-starter-web no longer transitively pulls the validation starter. jakarta.validation-api may arrive via other dependencies, allowing compilation but leaving @Validated inert at runtime. ValidationAutoConfiguration carries @ConditionalOnClass for the implementation; if absent, the entire configuration is skipped.
Call Patterns That Bypass the Proxy
The article enumerates scenarios where the proxy is not traversed:
Self-invocation : this.apply(cmd) inside the same class calls the target object directly, skipping the interceptor. The outer call to submit goes through the proxy, but the inner call does not.
final methods : CGLIB cannot override them; the JVM invokes the parent implementation directly. A final class causes IllegalArgumentException at proxy creation (fail-fast).
private methods : Subclasses cannot see them, so the interceptor never intercepts.
Manually instantiated objects : new Service(), reflection, or BeanUtils.instantiateClass produce raw instances without proxies. The author's incident stemmed from a static factory method in a util package that was originally written for unit tests but later reused in production code.
Aspect ordering : If a custom aspect runs before the validation interceptor (lower @Order), it sees unvalidated data and may throw NPEs, misleading debugging.
@Async methods : The proxy submits a task after the interceptor runs. However, if the async method internally calls another method on this, that inner call bypasses both proxies.
@Valid Cascading and Collection Elements
@Validtriggers cascading validation. On a nested address object, the annotation must be on the field; otherwise only the first layer is validated. For collections and maps, @Valid on the parameter validates the container itself, not its elements. Element validation requires the annotation on the generic type argument:
public void batchCreate(@RequestBody List<@Valid OrderCommand> commands) { }Missing this, a batch of 100 orders with blank orderNo in the first 99 passes validation. A null collection skips element validation entirely; @NotNull catches null, while @NotEmpty and @Size(min=1) handle both null and empty collections differently.
Return-value validation also relies on the proxy: with class-level @Validated, constraints on the return type are checked after method execution. This can surface historical dirty data as runtime exceptions — enable with caution.
Validation groups ( @Validated(OnCreate.class)) only match constraints declaring the same group. Constraints without explicit groups belong to Default; a mismatch silently skips validation. Common fix: interface OnCreate extends Default. @GroupSequence defines order; omitting Default from the sequence causes ungrouped base constraints to be skipped entirely — hard to catch in unit tests.
Exception Types and Response Mapping
Each validation path throws a distinct exception:
Request body: MethodArgumentNotValidException Controller simple params (6.1+): HandlerMethodValidationException Service layer: ConstraintViolationException A single @RestControllerAdvice catching only ConstraintViolationException misses the other two. ResponseEntityExceptionHandler handles the first two by default; ConstraintViolationException falls through to a 500 with a generic "Internal Server Error" body. Spring 6.1 introduced ProblemDetail (RFC 7807) as the default response structure, breaking frontends expecting legacy fields.
The article provides a complete handler example:
@RestControllerAdvice
public class ValidationAdvice extends ResponseEntityExceptionHandler {
@Override
protected ResponseEntity<Object> handleMethodArgumentNotValid(
MethodArgumentNotValidException ex, HttpHeaders h, HttpStatusCode s, WebRequest r) {
String msg = ex.getBindingResult().getFieldErrors().stream()
.map(e -> e.getField() + " " + e.getDefaultMessage())
.collect(Collectors.joining(", "));
return ResponseEntity.badRequest().body(ApiResult.fail("A0400", msg));
}
@ExceptionHandler(ConstraintViolationException.class)
public ResponseEntity<ApiResult> handleConstraint(ConstraintViolationException ex) {
String msg = ex.getConstraintViolations().stream()
.map(v -> v.getPropertyPath() + " " + v.getMessage())
.collect(Collectors.joining(", "));
return ResponseEntity.badRequest().body(ApiResult.fail("A0401", msg));
}
}Spring 6.1 offers a unification switch: declare a MethodValidationPostProcessor bean and call setAdaptConstraintViolations(true) to convert service-layer exceptions to MethodValidationException, which ResponseEntityExceptionHandler already handles. The trade-off: you own the post-processor configuration, and clients must be updated to the new error codes — the author witnessed a project where this change broke all frontend branch logic, turning messages into "System busy".
Distinguishing Validation Paths via Exception Details
Exception payloads reveal the executing mechanism: ConstraintViolationException.getPropertyPath() returns executable paths like create.orderNo (method name + parameter). MethodArgumentNotValidException.getFieldErrors() contains only field names (e.g., orderNo) without method context.
Comparing logs from both sources immediately identifies which validator intercepted the request.
Validation Execution Confirmation Checklist
Dependencies : Verify hibernate-validator in the dependency tree; jakarta.validation-api alone is insufficient. Use /actuator/conditions to inspect ValidationAutoConfiguration status — it even reports "bean already defined" conflicts.
Proxy presence : Check AopUtils.isAopProxy(bean); false means missing @Validated, unregistered post-processor, or non-container-managed instance.
Call origin : Determine whether the call enters via a container-managed proxy or via self-invocation, new, reflection, or a factory method.
Non-invasive debugging: temporarily set org.springframework.validation to DEBUG. The interceptor logs each method entry; absence of the log line proves the call bypassed the proxy. Revert immediately after reproduction.
Unit tests using new Service() or @MockBean do not exercise the proxy. Use @SpringBootTest to inject the real proxied bean. To test target-object behavior directly, unwrap with AopTestUtils.getTargetObject(proxy).
Version-Specific Behavior Change (Boot 3.2+)
In Boot 3.2+, if a @RestController also bears @Validated, the framework detects the existing method-validation proxy and skips the built-in 6.1 parameter validation. Consequently, constraint violations throw ConstraintViolationException (mapped to 500) instead of HandlerMethodValidationException (mapped to 400 with ProblemDetail). Upgrading projects see their custom error codes (e.g., A0400) disappear, replaced by generic 500 responses — the release notes mention only "built-in validation adaptation," but the impact cascades through exception type, response body, and frontend parsing logic.
Recommendation: Move Validation Forward
Place constraints on DTOs and annotate controller parameters with @Valid. Validation then occurs during argument parsing — no proxy required, immune to self-invocation, final / private methods, and manual instantiation. Reserve service-layer @Validated for genuine internal entry points shared across modules.
The opening incident — 300+ blank order_no rows — was not a missing annotation or dependency, but a missing proxy layer that only exists under specific call patterns. Stable validation demands either guaranteeing every call passes through the proxy, or eliminating the proxy dependency altogether by validating at the controller boundary.
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.
Java Tech Workshop
Focused on Java backend technologies, sharing fundamentals, multithreading, JVM, the Spring ecosystem, microservices, distributed systems, high concurrency, source‑code analysis, and practical experience. Continuously delivers high‑quality original content, interview guides, and learning roadmaps to help Java developers progress from beginner to advanced, enhancing technical skills and core competitiveness.
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.
