ZB Field Notes

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 annotateProxy?Where validation runsException thrown
@Valid @RequestBody DtoNoArgument resolverMethodArgumentNotValidException
@RequestParam @Min(1) int pNoBuilt-in HandlerMethodValidatorHandlerMethodValidationException
@Validated service methodYesMethodValidationInterceptorConstraintViolationException

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 (a BeanPostProcessor) sees the class-level @Validated and wraps the bean in a proxy — CGLIB by default in Boot. The proxy is created here.
  • At call time, the MethodValidationInterceptor inside that proxy validates the method's parameters before your body runs (and the return value after), delegating to Hibernate Validator's ExecutableValidator. 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 @Validated bean deeper in.
  • @Valid vs @Validated: @Valid is the Jakarta annotation and enables cascading into nested objects (List<@Valid Item>); @Validated is 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 ConstraintViolationException yourself 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.