Validation and a Global Exception Strategy

bee2026-10-0863 min read0 views
From the JSR-303 annotation family to validation groups and custom validators, plus a @RestControllerAdvice safety net that kills "validation failed with 500".
1 / 140
Section
0. The 30-second version
2 / 140

In plain words. Validation means "before doing anything, check whether what you were handed is acceptable": is the username empty, does the email contain an @, is the age at least 18? Exception handling means "after something blows up, how do you tell the outside world what actually broke". Together these two chores account for about half the code in a typical endpoint — and neither belongs inside business logic. Rules should hang on fields, and safety nets should live in one place.

3 / 140

Every term, explained once, because the whole article uses them:

4 / 140
  • annotation: a label you write in code, starting with @. It does nothing by itself; a framework reads it at runtime and acts. @NotBlank is only a sticker — Hibernate Validator is the one that actually checks
  • DTO: Data Transfer Object, a class whose only job is to receive the fields the client sends. It stays separate from the database entity, because the client may touch far fewer fields than the table has
  • BindingResult: the "scorecard" object of one validation run — which field failed and what the message says
  • @RestControllerAdvice: one global component that catches exceptions thrown by all controllers and turns them into a uniform response
  • HTTP status code: the server's one-word verdict on the request — 200 fine, 400 your parameters are wrong, 401 not signed in, 500 I broke
5 / 140
类比|Analogy

validation is airport security. Buying a ticket (sending a request) is never blocked; the check happens at the gate (before your controller runs), where an officer puts your bag through an X-ray machine against a fixed rulebook: too much liquid (@Size), an invalid document (@Pattern), a prohibited item in the case (@NotBlank failed). The rules live in the handbook, not in each flight attendant's head, so every flight ends the same way: rejected, with a clear statement of which bag broke which rule. Forgetting @Valid means nobody switched the machine on.

6 / 140
类比|Analogy

exception handling is a hospital triage desk. Patients (exceptions) arrive from every ward, and the nurse prescribes nothing — she does exactly three things: recognise the injury (match the exception type), decide the ward (map it to an HTTP status), log the case number (write the log and emit a traceId). The real danger is not an unattended patient but a sign on the door reading "all conditions, painkillers only" — that is an advice containing nothing but @ExceptionHandler(Exception.class): a fracture and a fever both come back as 500, and the actual cause gets swallowed.

7 / 140
Diagram
Figure · Chapter map: everything validation and exceptions cover
Figure · Chapter map: everything validation and exceptions cover
8 / 140

The four boxes above are the four question sets of this article: how to write the rules (Sections 3 and 6), when they trigger (Sections 2, 4 and 5), who catches the exception (Section 8), and what the response looks like (Sections 9 and 14).

9 / 140

After this article you should be able to answer three questions:

10 / 140
  • Why does the same "must not be empty" get copied a hundred times inside if statements but only once as @NotBlank?
  • My DTO clearly carries @NotBlank, yet an empty string still reached the database. Why? (Nine times out of ten, @Valid is missing.)
  • A BizException is thrown deep in a service — what makes it come back as 409 instead of a blank error page, and who caught it on the way?
11 / 140
Section
1. Why validation does not belong in business code
12 / 140

Start with a block almost everyone has written — the "null-check hell":

13 / 140
java
@PostMapping("/users")public Result<UserVO> create(@RequestBody Map<String, Object> body) {    String username = (String) body.get("username");    String email = (String) body.get("email");    Integer age = (Integer) body.get("age");    if (username == null || username.trim().isEmpty()) {        return Result.fail(40000, "username must not be empty");    }    if (username.length() > 32) {        return Result.fail(40000, "username must not exceed 32 characters");    }    if (email == null || !email.matches("^[\\w.-]+@[\\w.-]+$")) {        return Result.fail(40000, "invalid email format");    }    if (age != null && (age < 18 || age > 120)) {        return Result.fail(40000, "age must be between 18 and 120");    }    // ...finally the real business logic, already drowned in ifs    return Result.ok(userService.create(username, email, age));}
14 / 140

This has four problems: validation is tangled with business logic, every endpoint copies it again, the error shape is inconsistent, and a Map loses all typing. After annotation-based refactoring:

15 / 140
java
@PostMapping("/users")public Result<UserVO> create(@Valid @RequestBody UserCreateDTO dto) {    // only business remains in the business method    return Result.ok(userService.create(dto));}public record UserCreateDTO(        @NotBlank(message = "username must not be empty")        @Size(max = 32, message = "username must not exceed 32 characters")        String username,        @NotBlank(message = "email must not be empty")        @Email(message = "invalid email format")        String email,        @Min(value = 18, message = "age must be at least 18")        @Max(value = 120, message = "age must be at most 120")        Integer age) {}
16 / 140

The same rules now sit next to the fields — self-explanatory, reusable, and scannable by tooling. Every if is gone from the business method. That is the entire point of a validation framework.

17 / 140
Section
2. Dependencies and triggers: @Valid vs @Validated
18 / 140

Since Spring Boot 2.3, validation has been split out of spring-boot-starter-web and must be added explicitly:

19 / 140
xml
<dependency>    <groupId>org.springframework.boot</groupId>    <artifactId>spring-boot-starter-validation</artifactId></dependency>
20 / 140

It pulls in Hibernate Validator, which implements JSR-303 (Bean Validation). The two annotations look alike but play different roles:

21 / 140
Table
Aspect@Valid@Validated
OriginJSR-303 standard (jakarta.validation)Spring-specific (org.springframework.validation.annotation)
Group validationNot supportedSupports the groups attribute
TargetsParameters, fields, methodsClasses, methods, parameters
Can validate method parametersNoYes (annotate the class to validate @RequestParam etc.)
CascadingSupported (put @Valid on nested fields)Supported
22 / 140

One line to remember: use @Valid to validate a whole object, and @Validated when you need groups or to validate loose parameters.

23 / 140

Beyond the two annotations, you need to know which cell of the chain presses the switch. The chain below can be clicked through one stage at a time; stage ④ makes the point: validation is not a check you write in the business code, it is a round the framework runs after argument resolution finishes.

24 / 140
Diagram
FlowWhere validation sits before your controller method runs1 / 7
Click from ① to ⑦. Stages ③ and ④ matter most: binding and validation are two different jobs, and the switch is a third
→
→
→
→
→
→
① Request arrives
DispatcherServlet receives /api/users and resolves the create(...) handler by path and HTTP method. Not one field has been inspected at this point.
All clearBinding → switch → constraints → scorecard → exception → exit. Six cells, six separate responsibilities; when something fails, find your cell first.
25 / 140
Section
2.1 Cascading validation of nested objects
26 / 140
Code
Codejava
public record OrderCreateDTO(        @NotBlank String orderNo,        @NotNull @Valid AddressDTO address,          // @Valid is required to cascade into AddressDTO        @NotEmpty @Valid List<OrderItemDTO> items) {} // it also validates collection elementspublic record AddressDTO(        @NotBlank String province,        @NotBlank String city,        @NotBlank String detail) {}
Notes

Trap: if the nested object's fields carry constraints but the outer field lacks @Valid, validation is silently skipped — none of the @NotBlank rules inside AddressDTO fire and the request sails through. This is the classic hole in cascading validation.

27 / 140
Section
3. The constraint annotation family
28 / 140

There are twenty-odd constraints, but what beginners actually have to tell apart is what each one really stops. Skip the memorising — play a round instead: the left column is the annotation, the right column is its true reach, and every wrong pick explains itself on the spot.

29 / 140
Match
MatchAnnotation ↔ what it actually blocksMatched 0/7 · Missed 0
Seven hard mappings, both columns shuffled, so position tells you nothing — each miss explains the most common misuse
Pick a card on the left first
30 / 140
Key point

@NotNull / @NotEmpty / @NotBlank are the easiest trio to get wrong. To require that a name field is not an empty string you must use @NotBlank; with @NotNull, a client sending "" or " " passes and flows straight into the database.

31 / 140
Section
4. Validating method parameters: @Validated on the class
32 / 140

Loose parameters like @RequestParam and @PathVariable cannot take @Valid directly; you must annotate the class with @Validated:

33 / 140
java
@Validated@RestController@RequestMapping("/api/users")public class UserController {    // @Validated makes every method parameter constraint in the class effective    @GetMapping("/{id}")    public UserVO detail(            @PathVariable @Positive(message = "user id must be positive") Long id,            @RequestParam @Min(value = 1, message = "page starts at 1") int page) {        return userService.detail(id, page);    }}
34 / 140

Here a validation failure throws not MethodArgumentNotValidException but ConstraintViolationException — they are handled differently, so the global handler must cover both, or the error falls through to the 500 fallback.

35 / 140
Section
5. Validation groups in practice: create and update
36 / 140

For the same DTO, id must be empty on create (the database generates it) and non-empty on update. Groups express both rule sets with one DTO:

37 / 140
java
// 1) Two empty interfaces define the group markerspublic interface ValidGroups {    interface Create {}    interface Update {}}
38 / 140
java
public record UserDTO(        @Null(groups = ValidGroups.Create.class, message = "id must not be set on create")        @NotNull(groups = ValidGroups.Update.class, message = "id is required on update")        Long id,        @NotBlank(groups = {ValidGroups.Create.class, ValidGroups.Update.class},                  message = "username must not be empty")        String username) {}
39 / 140
Code
Codejava
@RestController@RequestMapping("/api/users")@Validatedpublic class UserController {    @PostMapping    public UserVO create(@Validated(ValidGroups.Create.class) @RequestBody UserDTO dto) {        return userService.create(dto);    }    @PutMapping("/{id}")    public UserVO update(@Validated(ValidGroups.Update.class) @RequestBody UserDTO dto) {        return userService.update(dto);    }}
Notes

Note: constraints without a groups attribute belong to the Default group, and only fire when @Validated names no group. The moment you write @Validated(Xxx.class), constraints lacking that group are skipped — the usual answer to "why did my validation suddenly stop working".

40 / 140
Animation
Animation · The group gate: why the Default group gets skipped
Animation · The group gate: why the Default group gets skipped
41 / 140

Remember the last frame of that animation: there are only two ways out — add groups to the constraint, or let your marker interface extends Default. The second one keeps every unlabelled rule working and touches the least code.

42 / 140
Section
6. A custom validation annotation: @Phone
43 / 140

When the built-ins cannot express a business rule, write your own. A phone check has two parts: the annotation and the validator.

44 / 140
java
@Target({ElementType.FIELD, ElementType.PARAMETER})@Retention(RetentionPolicy.RUNTIME)@Constraint(validatedBy = PhoneValidator.class)     // bind the validator@Documentedpublic @interface Phone {    String message() default "invalid phone number";    Class<?>[] groups() default {};    Class<? extends Payload>[] payload() default {};    /** Whether an empty value is allowed; defaults to true, pair with @NotBlank */    boolean required() default false;}
45 / 140
java
public class PhoneValidator implements ConstraintValidator<Phone, String> {    private static final Pattern CN_MOBILE = Pattern.compile("^1[3-9]\\d{9}$");    private boolean required;    @Override    public void initialize(Phone annotation) {        this.required = annotation.required();    }    @Override    public boolean isValid(String value, ConstraintValidatorContext context) {        if (value == null || value.isBlank()) {            return !required;        // an empty optional value counts as valid        }        return CN_MOBILE.matcher(value).matches();    }}
46 / 140
java
public record ProfileUpdateDTO(        @NotBlank String nickname,        @Phone(required = true, message = "please provide a valid phone number")        String mobile) {}
47 / 140

Each method has a job: initialize reads the annotation attributes, isValid decides, and message/groups/payload are the required members the spec demands (missing them fails startup). A custom validator must be thread-safe — compile the Pattern once and reuse a single instance.

48 / 140
Section
7. Internationalizing validation messages
49 / 140

Hard-coded text serves one language only. Pull the messages into resource bundles and multiple languages come for free:

50 / 140
properties
# src/main/resources/messages.properties (default, English fallback)NotBlank=must not be blankSize=size must be between {min} and {max}Phone=invalid phone number# src/main/resources/messages_zh_CN.propertiesNotBlank=不能为空Size=长度必须在 {min} 到 {max} 之间Phone=手机号格式不正确
51 / 140

Point at the bundle and its encoding:

52 / 140
yaml
spring:  messages:    basename: messages    encoding: UTF-8
53 / 140

With i18n in place, put a placeholder key in the constraint's message rather than literal text, e.g. @NotBlank(message = "{NotBlank}"), and MessageSource resolves it by the request Locale at runtime.

54 / 140
Section
8. A global exception system (the centerpiece)
55 / 140

By now every validation failure throws an exception. Without unified handling, Spring returns its default error page or a stack dump — the front end receives a 500 or HTML instead of a structured "which field failed".

56 / 140

@RestControllerAdvice + @ExceptionHandler is the only proper answer. Here is a complete implementation you can copy:

57 / 140
java
package com.example.common.exception;import com.example.common.api.ErrorCode;import com.example.common.api.Result;import jakarta.servlet.http.HttpServletRequest;import jakarta.validation.ConstraintViolationException;import lombok.extern.slf4j.Slf4j;import org.springframework.http.HttpStatus;import org.springframework.validation.BindException;import org.springframework.validation.FieldError;import org.springframework.web.HttpRequestMethodNotSupportedException;import org.springframework.web.bind.MethodArgumentNotValidException;import org.springframework.web.bind.annotation.ExceptionHandler;import org.springframework.web.bind.annotation.ResponseStatus;import org.springframework.web.bind.annotation.RestControllerAdvice;import org.springframework.web.servlet.NoHandlerFoundException;import java.util.List;import java.util.UUID;/** * The global safety net: translate every exception into a unified Result + the right HTTP status. */@Slf4j@RestControllerAdvicepublic class GlobalExceptionHandler {    /** 1) @RequestBody object validation failed */    @ResponseStatus(HttpStatus.BAD_REQUEST)    @ExceptionHandler(MethodArgumentNotValidException.class)    public Result<List<FieldErrorVO>> handleInvalidBody(MethodArgumentNotValidException e) {        List<FieldErrorVO> errors = e.getBindingResult().getFieldErrors().stream()                .map(this::toFieldError)                .toList();        return Result.fail(ErrorCode.INVALID_PARAM.getCode(), "validation failed", errors);    }    /** 2) Form / @ModelAttribute object validation failed */    @ResponseStatus(HttpStatus.BAD_REQUEST)    @ExceptionHandler(BindException.class)    public Result<List<FieldErrorVO>> handleBind(BindException e) {        List<FieldErrorVO> errors = e.getBindingResult().getFieldErrors().stream()                .map(this::toFieldError)                .toList();        return Result.fail(ErrorCode.INVALID_PARAM.getCode(), "validation failed", errors);    }    /** 3) @Validated on the class validating loose parameters failed */    @ResponseStatus(HttpStatus.BAD_REQUEST)    @ExceptionHandler(ConstraintViolationException.class)    public Result<List<FieldErrorVO>> handleConstraint(ConstraintViolationException e) {        List<FieldErrorVO> errors = e.getConstraintViolations().stream()                .map(v -> new FieldErrorVO(lastNode(v.getPropertyPath()), v.getMessage()))                .toList();        return Result.fail(ErrorCode.INVALID_PARAM.getCode(), "validation failed", errors);    }    /** 4) Business exception: the error code carries its own status */    @ExceptionHandler(BizException.class)    public org.springframework.http.ResponseEntity<Result<Void>> handleBiz(            BizException e, HttpServletRequest request) {        ErrorCode ec = e.getErrorCode();        log.warn("biz exception: {} {} -> {}", request.getMethod(), request.getRequestURI(), e.getMessage());        return org.springframework.http.ResponseEntity                .status(ec.getHttpStatus())                .body(Result.fail(ec.getCode(), e.getMessage()));    }    /** 5) No handler found -> 404 */    @ResponseStatus(HttpStatus.NOT_FOUND)    @ExceptionHandler(NoHandlerFoundException.class)    public Result<Void> handleNotFound(NoHandlerFoundException e) {        return Result.fail(ErrorCode.RESOURCE_NOT_FOUND);    }    /** 6) Method not allowed -> 405 */    @ResponseStatus(HttpStatus.METHOD_NOT_ALLOWED)    @ExceptionHandler(HttpRequestMethodNotSupportedException.class)    public Result<Void> handleMethod(HttpRequestMethodNotSupportedException e) {        return Result.fail(ErrorCode.METHOD_NOT_ALLOWED.getCode(), e.getMessage());    }    /** 7) Fallback: unknown exceptions -> 500, with a traceId for debugging */    @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)    @ExceptionHandler(Exception.class)    public Result<Void> handleUnknown(Exception e, HttpServletRequest request) {        String traceId = UUID.randomUUID().toString().replace("-", "").substring(0, 16);        log.error("unhandled exception, traceId={}, uri={}", traceId, request.getRequestURI(), e);        return Result.fail(ErrorCode.SYSTEM_ERROR.getCode(),                "the service is busy, please retry later (traceId: " + traceId + ")");    }    private FieldErrorVO toFieldError(FieldError fe) {        return new FieldErrorVO(fe.getField(), fe.getDefaultMessage());    }    private String lastNode(jakarta.validation.Path path) {        String s = path.toString();        int i = s.lastIndexOf('.');        return i >= 0 ? s.substring(i + 1) : s;    }    public record FieldErrorVO(String field, String message) {}}
58 / 140

Key points:

59 / 140
  • Handler methods match by specificity — the BizException handler is more specific than the Exception fallback, so a business exception is always caught by it first
  • Every handler pins the HTTP status with @ResponseStatus or ResponseEntity, instead of always returning 200
  • The fallback Exception handler is a net, not a bin: its job is to miss nothing, not to swallow anything — log.error the full stack and return only the traceId to the user
60 / 140
Section
9. Designing the error response
61 / 140

A unified error response carries three things: a machine-readable error code, a human-readable message, and field-level detail. A validation failure looks like this:

62 / 140
Code
Codejson
{  "code": 40000,  "message": "validation failed",  "data": [    { "field": "username", "message": "username must not exceed 32 characters" },    { "field": "email", "message": "invalid email format" },    { "field": "address.city", "message": "city must not be empty" }  ]}
Notes
  • code: the business error code, which the front end can act on (e.g. 40100 redirects to login)
  • message: a user-facing fallback sentence
  • data: a field-error list on validation failure, business data on success. Carried by the T type, so the shape does not fork
  • On system errors it also carries a traceId: the user quotes it in a report and you locate that exact request in the logs
63 / 140
Animation
Animation · One violation, from request body to 400
Animation · One violation, from request body to 400
64 / 140

That data array did not appear out of thin air: it is frame ④ of the animation, expanded. To see how the scorecard is written page by page and then read back, step through the debugger below — the same MethodArgumentNotValidException, from the throw to the 400, travels exactly one resolver chain:

65 / 140
Stepper
StepperStep through it: how a validation exception finds its exit1 / 7
Seven steps in real execution order. Step 4 is the one to remember: matching is by type closeness, not by the order you wrote the methods
Code under debug
1create(dto); // the method body never executes
2// DispatcherServlet.doDispatch(...) catches the exception just thrown
3processDispatchResult(request, response, mv, exception);
4// iterate handlerExceptionResolvers, order fixed by the container
5mv = resolver.resolveException(request, response, handler, exception);
6// ExceptionHandlerExceptionResolver: look for an @ExceptionHandler method
7mapped = findMethod(exception); // closest type wins
8return new ResponseEntity<>(body, HttpStatus.BAD_REQUEST);
Variables now
exception typeMethodArgumentNotValidException
offending argumentargument [0]
method body executedno
Call stack
—
1The exception was thrown during argument resolution — your create(...) body never ran. This is why "my logging line never printed" happens.
66 / 140
Animation
Animation · Who catches the exception
Animation · Who catches the exception
67 / 140
Section
10. Two high-frequency traps
68 / 140
  • @Valid on a primitive parameter does nothing: @Valid @RequestParam String name triggers no validation — @Valid only means "start validating an object / cascade", it cannot add a constraint to a string. For loose parameters, put @Validated on the class and write @NotBlank at the parameter
  • Validation exceptions swallowed by the fallback: if you only write @ExceptionHandler(Exception.class), MethodArgumentNotValidException gets taken by it during matching (it is an Exception too), so a validation failure returns 500. Either write a dedicated handler for validation exceptions, or restrict the fallback to truly unexpected errors — the deciding factor is specificity, not the order in the source
69 / 140

Figure 1 gives the whole pipeline view:

70 / 140
Diagram
Figure 1 · Validation and exception pipeline
Figure 1 · Validation and exception pipeline
71 / 140
Kernel lab
TeaVMWhere validation sits in the request pipelineidle
Use /users/42 to watch conversion during argument resolution and see that validation runs after it
Scenario
Click “Run demo” to execute the AOT-compiled Java kernel right in your browser, step by step.
72 / 140
Section
11. Hands-on labs: run all four chains yourself
73 / 140

Reading ten diagrams is worth less than clicking once. Run these six kernel experiments in order — about 30 seconds each — they cover the six scenes of validation and exceptions.

74 / 140

First, understand how your parameters get into the method at all. @PathVariable, @RequestParam and @RequestBody follow three different resolver paths, and validation runs only after resolution succeeds. Click through all five arguments, and pay special attention to the last one, "no resolver" — that is exactly the error you have seen in your own project:

75 / 140
Kernel lab
TeaVMThe argument resolver chain: where validation slots inidle
Step through pathvar / reqparam / body / special, then hit none to see the error when nobody takes it
Scenario
Click “Run demo” to execute the AOT-compiled Java kernel right in your browser, step by step.
76 / 140

Next, look at how the response gets written out. A validation failure has to become JSON, a 406 comes from the message converters, and @Validated on the class throws a different exception — all three are decided by this chain:

77 / 140
Kernel lab
TeaVMContent negotiation: how the error body becomes JSONidle
Watch json and accept first, then fail to see which step raises 406
Scenario
Click “Run demo” to execute the AOT-compiled Java kernel right in your browser, step by step.
78 / 140

This is the centrepiece: the exception resolver chain. The five arguments map to five fates — validation 400, conversion failure, caught by your own @ExceptionHandler, swept up by @ControllerAdvice, and the worst one, "nobody handles it → 500". Do run the none case; it prints where the blank error page actually comes from:

79 / 140
Kernel lab
TeaVMThe exception resolver chain: who took your exceptionidle
Go valid → convert → handler → status → none and watch the final HTTP status of each step
Scenario
Click “Run demo” to execute the AOT-compiled Java kernel right in your browser, step by step.
80 / 140

Finally zoom out and see which stop validation and exceptions occupy as one request crosses the whole application. Compare valid (validation failed) with db (database down): both fail, one at the entrance and one at the bottom layer — but both leave through the same global advice:

81 / 140
Kernel lab
TeaVMOne request across all layers: which door does an error leave byidle
Compare happy / valid / biz / db and notice every error is translated in the same place
Scenario
Click “Run demo” to execute the AOT-compiled Java kernel right in your browser, step by step.
82 / 140

One more trap you only feel once you click it: drop a single @ResponseBody and the string you return is treated as a view name to be resolved against templates, so the browser receives a blank page instead of JSON. Getting the exit's shape wrong is exactly as damaging as getting the status code wrong. Walk all four cases, and missing in particular:

83 / 140
Kernel lab
TeaVM@Controller versus @RestController: what a return value is taken to meanidle
Go view → json → missing → string: watch the same return "error" turn into a view name, a JSON body, or a 404 blank page in the two controller flavours
Scenario
Click “Run demo” to execute the AOT-compiled Java kernel right in your browser, step by step.
84 / 140

Once those six are done, replay the animation from the opening — now every step should map onto something you just clicked: step 3's ExceptionHandlerExceptionResolver is the err lab, and step 4's "most specific first" is exactly why the Exception.class only cell of the sandbox blows up:

85 / 140
Animation
Animation · Who catches the exception (replay)
Animation · Who catches the exception (replay)
86 / 140
Section
11.1 Kernel console: type the conclusions yourself
87 / 140

The labs are clicked, now it is your turn at the prompt. This console is a real container and every line of output is computed by the Java kernel in your browser — beans tells you whether your advice was ever installed, lab runs the six scenarios above:

88 / 140
Console
89 / 140

If beans does not list GlobalExceptionHandler, it lives outside the base package of your main class — then no handler runs at all, and that check is worth doing before you audit annotation spellings. Read commands 3 and 4 back to back: that pair is the watershed between 400 and the blank 500 page. The last two show the same return "error" becoming a view name or a JSON body depending on which controller you chose.

90 / 140
Section
12. Decision: hard-coded messages or i18n bundles
91 / 140
Decision
Decisionthis is a new domestic project with no overseas users for now. Should validation messages be hard-coded Chinese, or moved to internationalized message bundles?
92 / 140
Section
13. Sandbox: what does this mistake actually return?
93 / 140

"An annotation means validation happens" is the biggest beginner illusion. Drag the three switches below and the real outcome appears instantly — learn the table first, memorise the rules later:

94 / 140
Sandbox
SandboxDoes this line of code actually block bad data
Result
No matching result
95 / 140

How to read this sandbox: look at the Trigger column first. Whenever it says none, validation never runs regardless of the other two switches. Then check "Constraints sit on", because loose parameters and nested objects are two special branches. Finally look at "Global handler" — it decides whether this failure reaches the client as a clean 400 or as a pile of 500.

96 / 140
Section
14. Common errors, searchable by exact wording
97 / 140

What beginners fear most is a log too long to read. Every fragment below can be copied verbatim into a search engine — do not paraphrase it, search engines match fully qualified class names, not your description.

98 / 140

Start with the scorecard you most need to learn to read. When MethodArgumentNotValidException is thrown, everything useful lives in the BindingResult, yet most people only stare at the top of the stack:

99 / 140
text
org.springframework.web.bind.MethodArgumentNotValidException: Validation failed for argument [0] in public com.example.api.Result com.example.user.UserController.create(com.example.user.UserCreateDTO): [Field error in object 'userCreateDTO' on field 'username': rejected value []; codes [NotBlank.userCreateDTO.username,NotBlank.username,NotBlank.java.lang.String,NotBlank]; arguments [...]; default message [username must not be empty]]
100 / 140

Three things to extract: ① for argument [0] means the 0th method parameter failed, not the return value; ② on field 'username' is the real offending field, mapped straight onto your DTO property; ③ rejected value [] shows what was actually received (an empty string here) and default message [...] is the sentence you will send back to the client. The fixed way to read it is e.getBindingResult().getFieldErrors(), and each FieldError gives you getField() / getRejectedValue() / getDefaultMessage().

101 / 140

The stack worth learning to read is this kind: it explodes at startup or on the first request, it is long, and only one line is actually guilty. Click through it and see which frames merely passed by:

102 / 140
Triage
Error triageUnexpectedTypeException: HV000030
@NotBlank placed on an Integer field

Everything worked locally; after one DTO field changed type the app fails on startup and the client only ever sees a 500.

jakarta.validation.UnexpectedTypeException: HV000030: No validator could be found for constraint 'jakarta.validation.constraints.NotBlank' validating type 'java.lang.Integer'. Check configuration for 'age'
at org.hibernate.validator.internal.engine.constraintvalidation.ConstraintValidatorManager.createValidatorInstance(ConstraintValidatorManager.java:126)
at org.hibernate.validator.internal.engine.ConstraintTree.validateSingleConstraint(ConstraintTree.java:171)
at org.springframework.validation.BeanValidator.validateDataInternal(BeanValidator.java:199)
at com.example.user.UserController.create(UserController.java:38)
Click the frame you blame — guessing is allowed
No pressure: guess the exception first, then which line actually made the call.
103 / 140

That resolver chain from the debugger is the model to keep: the same exception becomes a helpful 400 or a useless 500 purely depending on which exit catches it. That is also why a global advice beats per-method try-catch:

104 / 140
Diagram
Figure · Try-catch everywhere versus one global advice
Figure · Try-catch everywhere versus one global advice
105 / 140
Table
Error text (fragment)Real cause30-second fixDig deeper
Validation failed for argument [0] ... [Field error in object 'xxx' on field 'username': rejected value []]@Valid @RequestBody validation failed and the framework threw MethodArgumentNotValidExceptionThis is not a bug — it is the 400 you were waiting for. Confirm an @ExceptionHandler(MethodArgumentNotValidException.class) exists and read fields via getBindingResult().getFieldErrors()This section + Section 8
The constraint annotation is there, yet the empty string reached the database (and no error at all)@Valid / @Validated is missing on the parameter, so validation was never triggered — annotations are labels, somebody has to press the switchAdd @Valid before @RequestBody; for loose parameters put @Validated on the classSections 2 and 4
jakarta.validation.ConstraintViolationException: detail.arg1: page starts at 1Class-level @Validated validating method parameters throws ConstraintViolationException, not MethodArgumentNotValidExceptionCover both in the advice; read fields by iterating e.getConstraintViolations() and getPropertyPath()Section 4
MethodArgumentNotValidException logged but the client sees 500Only @ExceptionHandler(Exception.class) exists, so the broadest handler wins the matching roundGive validation exceptions their own handler; reserve the fallback for genuine surprisesSection 10
HV000030: No validator could be found for constraint 'jakarta.validation.constraints.NotBlank' validating type 'java.lang.Integer'Wrong constraint for the type: @NotBlank only supports CharSequence but was placed on an IntegerUse @NotNull / @Min for numbers and @NotEmpty for collectionsSection 3
Unable to create a Configuration, because no Bean Validation provider could be foundSpring Boot moved validation out of spring-boot-starter-web since 2.3 and the starter is missingAdd the spring-boot-starter-validation dependencySection 2
`JSON parse error: Cannot deserialize value of type java.lang.Integer from String "abc" ``The body field cannot be converted, throwing HttpMessageNotReadableException — this happens before validationHandle it separately and return 400 saying "wrong field type"; validation annotations cannot help hereSection 11 (conv lab)
org.springframework.validation.BindException: Validation failed for bean [userDTO] ... (forms and query strings)Non-JSON binding throws BindException, while the advice only covers the @RequestBody pathAdd @ExceptionHandler(BindException.class) sharing the same field-mapping helperSection 8
Opening the endpoint in a browser returns a plain HTML error page, and none of your business logs appearNothing matched any @ExceptionHandler, so the request was forwarded to /error (BasicErrorController) which rendered its default pageWrite a @RestControllerAdvice fallback; while debugging set server.error.include-message=always and server.error.include-stacktrace=always, then turn them off againEnd of this section + Section 13 sandbox
Group validation that worked yesterday suddenly stops applyingYou switched to @Validated(Create.class), and constraints without a groups attribute belong to the Default group, which is now skippedAdd the group explicitly, or let the group marker interface extends DefaultSection 5
Startup fails with must define the following attributes: [message, groups, payload] for your custom @PhoneThe annotation misses the three members the spec requires, or @Constraint(validatedBy = ...) does not point at your validatorAdd message() / groups() / payload() and check the validatedBy targetSection 6
106 / 140
Tip

search only the first segment after the colon (for example No validator could be found for constraint). Different Spring versions append extra sentences, so shorter quotes hit far more often.

107 / 140
Section
14.1 Config generator: switch on exactly what you need while triaging
108 / 140

Three failure classes in the table above — the blank page, the bare 500, the missing message — are usually resolved by making the framework show its work first. All of those switches must be off in production, so generate them instead of hand-copying:

109 / 140
Generator
GeneratorTriage-time configuration in one passapplication.yml2 / 3
Tick "server" alone first and look at the server.error.* lines that expose message and trace to the client; then add "logging" to get the level of validation failures right; finally tick "profile" so those lines only ever apply to dev — compare with the last cell of the sandbox in Section 13
Output
server:
  port: 8080
  servlet:
    encoding: { charset: UTF-8, enabled: true, force: true }
  compression: { enabled: true, min-response-size: 2048 }

spring:
  application:
    name: demo-service

logging:
  level:
    root: INFO
    com.example.demoservice: DEBUG
    org.springframework.jdbc.core.JdbcTemplate: DEBUG   # 打 SQL 与参数
  file:
    name: logs/app.log
  logback:
    rollingpolicy: { max-file-size: 50MB, max-history: 14 }
Why each choice matters
serverserver.port loses to --server.port=8081 on the command line and to the SERVER_PORT env var.
loggingLevels work per package; root=DEBUG floods you with third-party output — never in production.
110 / 140

Check the result against one rule: include-message / include-stacktrace belong to dev only. In production the only thing the client may learn is the traceId your own advice writes — the framework's words should never be spoken to the front end.

111 / 140
Section
15. Quick quizzes
112 / 140
Quiz
Check yourselfA DTO field carries @NotBlank, yet an empty string from the client still gets stored. What is the most likely reason?
Pick one — you get feedback right away
113 / 140
Quiz
Check yourselfAn advice contains only @ExceptionHandler(Exception.class). What does the client receive when @Valid fails?
Pick one — you get feedback right away
114 / 140
Section
16. Exercises in three tiers
115 / 140
Section
Tier 1 · Follow along
116 / 140

Build an endpoint that really returns field errors, end to end. Step one is the dependency (mandatory since Boot 2.3):

117 / 140
xml
<dependency>    <groupId>org.springframework.boot</groupId>    <artifactId>spring-boot-starter-validation</artifactId></dependency>
118 / 140
java
package com.example.valid;import jakarta.validation.constraints.*;public record SignUpDTO(        @NotBlank(message = "username must not be blank")        @Size(max = 32, message = "username must not exceed 32 characters")        String username,        @NotBlank(message = "email must not be blank")        @Email(message = "invalid email format")        String email,        @Min(value = 18, message = "age must be at least 18")        Integer age) {}
119 / 140
java
package com.example.valid;import jakarta.validation.ConstraintViolationException;import org.springframework.http.HttpStatus;import org.springframework.web.bind.MethodArgumentNotValidException;import org.springframework.web.bind.annotation.*;import java.util.List;@RestControllerAdviceclass ApiErrors {    record FieldErr(String field, String message) {}    @ResponseStatus(HttpStatus.BAD_REQUEST)    @ExceptionHandler(MethodArgumentNotValidException.class)    List<FieldErr> invalidBody(MethodArgumentNotValidException e) {        return e.getBindingResult().getFieldErrors().stream()                .map(fe -> new FieldErr(fe.getField(), fe.getDefaultMessage()))                .toList();    }    @ResponseStatus(HttpStatus.BAD_REQUEST)    @ExceptionHandler(ConstraintViolationException.class)    List<String> invalidParam(ConstraintViolationException e) {        return e.getConstraintViolations().stream()                .map(v -> v.getPropertyPath() + ": " + v.getMessage())                .toList();    }}
120 / 140
Code
Codejava
package com.example.valid;import jakarta.validation.Valid;import jakarta.validation.constraints.Positive;import org.springframework.validation.annotation.Validated;import org.springframework.web.bind.annotation.*;@Validated                      // remove this line and @Positive below does nothing@RestController@RequestMapping("/api/signup")class SignUpController {    @PostMapping    String create(@Valid @RequestBody SignUpDTO dto) {        return "ok:" + dto.username();    }    @GetMapping("/{id}")    String detail(@PathVariable @Positive(message = "id must be positive") Long id) {        return "detail:" + id;    }}
Notes

Note: the class-level line is org.springframework.validation.annotation.Validated, not jakarta.validation.Valid — similar names, completely different packages, and one wrong import compiles into a different meaning. Get it running as-is, then delete that line and compare, as Tier 2 asks. It is the fastest possible feel for "rules versus switch".

121 / 140

Send one good request and two bad ones:

122 / 140
bash
curl -i -X POST http://localhost:8080/api/signup \     -H "Content-Type: application/json" \     -d '{"username":"","email":"not-an-email","age":17}'
123 / 140

Expected response (status line and body aligned word for word):

124 / 140
text
HTTP/1.1 400Content-Type: application/json[{"field":"username","message":"username must not be blank"}, {"field":"email","message":"invalid email format"}, {"field":"age","message":"age must be at least 18"}]
125 / 140

The console should show no ERROR stack trace — a validation failure is an expected 4xx, and logging it as an error drowns the real incidents. If you get an HTML error page instead, ApiErrors was never scanned (its package sits outside the main class's tree).

126 / 140
Section
Tier 2 · Variants
127 / 140

Goal: three one-line edits, three completely different failure shapes.

128 / 140
  1. Delete @Valid from the create parameter, change nothing else, resend the same curl. You will observe HTTP 200, an empty username after ok:, and not even a warning in the log — a silent skip, which is worse than an error.
  2. Change the first handler's parameter type in ApiErrors from MethodArgumentNotValidException to Exception, restore @Valid, resend. You will observe the status becoming 500, the body becoming Boot's default {timestamp,status,error,path}, and an ERROR stack appearing in the log — the fallback stole the validation exception.
  3. Also delete the class-level @Validated, then request /api/signup/-1. You will observe no ConstraintViolationException at all, just a clean detail:-1 — loose parameters are validated only because of the class-level @Validated; a lone @Positive on the parameter does nothing.
129 / 140
Section
Tier 3 · Build one
130 / 140

Build an POST /api/orders endpoint that strings every capability of this article together: the body contains a shipping address (nested object) and a list of order lines (collection elements), and create/update share one DTO with different rules.

131 / 140

Acceptance checklist:

132 / 140
  • [ ] Validation on the nested AddressDTO and on List<OrderItemDTO> actually fires (send one illegal element and confirm a field name with an index, like items[1].skuId, appears in the response)
  • [ ] create uses ValidGroups.Create and update uses ValidGroups.Update, while shared rules such as @NotBlank apply under both groups (via explicit groups or by letting the marker interface extend Default)
  • [ ] At least one custom annotation (e.g. @Phone), returning your own message on bad input
  • [ ] The global advice covers at least five types: MethodArgumentNotValidException, BindException, ConstraintViolationException, BizException, and the Exception fallback
  • [ ] The fallback body carries a traceId, and grepping that same string finds the full stack in the log
  • [ ] Curl produces four distinct statuses: 200 / 400 (validation) / 409 or a business code (insufficient stock) / 500 (throw a RuntimeException deliberately)
  • [ ] You can explain why validation failures should be logged at warn, not error
133 / 140
Section
17. Self-check
134 / 140
自检

Can you list the three ways to trigger validation in one breath? @Valid on the parameter (an object), @Validated on the class (loose parameters), @Validated(Group.class) (groups). Missing one means their division of labour is still fuzzy.

135 / 140
自检

Who throws MethodArgumentNotValidException and who throws ConstraintViolationException? Which accessor reads the field errors in each case (getBindingResult().getFieldErrors() versus getConstraintViolations())?

136 / 140
自检

Why does having only @ExceptionHandler(Exception.class) turn a validation failure into a 500? The keyword must be "matching by exception type, most specific first".

137 / 140
自检

How do @NotBlank / @NotEmpty / @NotNull each treat " " (whitespace only), and which single one rejects it?

138 / 140
自检

Facing a plain HTML error page, can you immediately say "no HandlerExceptionResolver took this"? Which two places in your code do you check next?

139 / 140
口诀

annotations are the rules, @Valid is the switch; name a group and Default steps aside; match the narrowest exception first and never let the fallback steal it; fields into the Result, status correct, traceId left in the log.

140 / 140
Summary

the goal of validation is not "write more @NotBlank" but to pull the rules out of business code, attach them to fields, and let one unified pipeline catch errors for every endpoint. JSR-303 annotations express the rules, @Validated triggers them (including groups and loose parameters), custom validators handle business special cases, and @RestControllerAdvice translates all of it into responses with correct structure and status. Do the last part and "validation failed with 500" never happens again.