Spring MVC Won't Throw ConstraintViolationException (Here's What It Throws Instead)
The surprise that starts every Bean Validation debugging session
You put @Min(18) on a controller parameter, send a bad request, and reach for the
ConstraintViolationException handler you were sure you'd need. It never fires. Instead
Spring throws something you didn't write code for: MethodArgumentNotValidException or
HandlerMethodValidationException. The constraint did run — Hibernate Validator
is right there on the classpath via spring-boot-starter-validation — but in Spring MVC
the failure surfaces as a Spring web exception, not the raw Jakarta one.
I spent a session pinning down exactly why, on Spring Boot 4.1 (Java 25), and the short version is:
in a web app there are three validation mechanisms, they fire in different places, and
only one of them hands you a ConstraintViolationException. Here's the map.
Three mechanisms, three exceptions
The exception type is a reliable fingerprint of which mechanism actually ran:
| What you annotate | Proxy? | Where validation runs | Exception thrown |
|---|---|---|---|
@Valid @RequestBody Dto | No | Argument resolver | MethodArgumentNotValidException |
@RequestParam @Min(1) int p | No | Built-in HandlerMethodValidator | HandlerMethodValidationException |
@Validated service method | Yes | MethodValidationInterceptor | ConstraintViolationException |
So the rule of thumb: at the MVC boundary you get Spring exceptions; only deeper in a
@Validated Spring bean do you get the raw Jakarta ConstraintViolationException.
That single distinction explains most "my handler never fires" confusion.
When — and which instance — actually proxies
The only mechanism that uses an AOP proxy is method validation on a regular Spring bean. It's worth being precise about when that proxy appears and who builds it, because there are two different moments people conflate:
- At container startup,
MethodValidationPostProcessor(aBeanPostProcessor) sees the class-level@Validatedand wraps the bean in a proxy — CGLIB by default in Boot. The proxy is created here. - At call time, the
MethodValidationInterceptorinside that proxy validates the method's parameters before your body runs (and the return value after), delegating to Hibernate Validator'sExecutableValidator. The validation executes here.
Critically, the proxy is keyed off the class-level @Validated, not off
the parameter constraints. Drop @Validated and your @Min is silently ignored —
no proxy, no validation, HTTP 200 with garbage.
Why controllers don't need that proxy anymore
Historically you'd put @Validated on a controller to validate @RequestParam
and @PathVariable, and you'd get a ConstraintViolationException from the proxy.
Since Spring Framework 6.1 (Boot 3.2+), and very much on Boot 4.1, controller method validation is
built into the web layer. The RequestMappingHandlerAdapter invokes a
HandlerMethodValidator directly — no AOP proxy — and failures become
HandlerMethodValidationException, which Spring maps straight to HTTP 400. If you do also add
@Validated to the controller, Spring detects the existing proxy and skips the built-in pass
to avoid validating twice. Net effect: on modern Spring you usually don't annotate controllers with
@Validated at all.
And @Valid @RequestBody never used a proxy in any Spring version — the argument resolver
(RequestResponseBodyMethodProcessor) deserializes the body and validates it inline,
producing MethodArgumentNotValidException (a BindException subclass).
The proof: same bad input, three different exceptions
I wired one endpoint per mechanism and ran the app on port 18080. The exception name is echoed back in each response so there's no ambiguity:
POST /users {"name":"a","email":"nope","age":10}
400 MethodArgumentNotValidException # @Valid @RequestBody
GET /users?page=0
400 HandlerMethodValidationException # built-in controller param validation
GET /users/promote?age=5
400 ConstraintViolationException # @Validated service via proxy
Look at the message shapes too — they leak the layer. The service-proxy violation reads
promote.age: ... (an executable path: method name + parameter), while the request
body violations read plain name:, email:, age: (an object
property path). When you're staring at a stack trace, that path shape tells you which validator fired.
The self-invocation trap
Because mechanism #3 is AOP, it has the classic proxy blind spot: calling a validated method from within the same bean goes straight to the target object and skips the interceptor entirely.
@Service
@Validated
public class UserService {
public String promote(@Min(18) int age) { ... } // validated via the proxy
public String promoteViaSelfInvocation(int age) {
return this.promote(age); // proxy BYPASSED, age = 5 sails through
}
}
Hitting that path with age=5 returns HTTP 200. No exception, no validation. The two
web-layer mechanisms don't have this failure mode precisely because they aren't proxy-based.
The 400-vs-500 gotcha
One more sharp edge: Spring auto-maps the two web-aware exceptions
(MethodArgumentNotValidException, HandlerMethodValidationException) to 400 out
of the box. It does not do that for ConstraintViolationException — left
unhandled, a violation thrown from a @Validated service becomes an HTTP 500. So if you push
validation down into the service layer, you owe it an explicit handler:
@RestControllerAdvice
class GlobalExceptionHandler {
@ExceptionHandler(ConstraintViolationException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
Object handle(ConstraintViolationException ex) { ... }
}
Rules I'm keeping
- Identify the layer by the exception. Spring exception ⇒ MVC boundary;
ConstraintViolationException⇒ a@Validatedbean deeper in. @Validvs@Validated:@Validis the Jakarta annotation and enables cascading into nested objects (List<@Valid Item>);@Validatedis Spring's, and it's what powers the proxy and validation groups.- Don't self-invoke a validated method and expect it to validate.
- Map
ConstraintViolationExceptionyourself or eat a 500. - Violations are an unordered set — never depend on which one comes back first.
None of this is exotic; it's just that "Bean Validation in Spring MVC" is really three features wearing one annotation vocabulary. Once you read the exception type as a signal of which feature ran, the debugging gets a lot quieter.