Validation and a Global Exception Strategy
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.
Every term, explained once, because the whole article uses them:
- annotation: a label you write in code, starting with
@. It does nothing by itself; a framework reads it at runtime and acts.@NotBlankis 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 —
200fine,400your parameters are wrong,401not signed in,500I broke
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.
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.

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).
After this article you should be able to answer three questions:
- Why does the same "must not be empty" get copied a hundred times inside
ifstatements but only once as@NotBlank? - My DTO clearly carries
@NotBlank, yet an empty string still reached the database. Why? (Nine times out of ten,@Validis missing.) - A
BizExceptionis thrown deep in a service — what makes it come back as409instead of a blank error page, and who caught it on the way?
Start with a block almost everyone has written — the "null-check hell":
@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));}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:
@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) {}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.
Since Spring Boot 2.3, validation has been split out of spring-boot-starter-web and must be added explicitly:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId></dependency>It pulls in Hibernate Validator, which implements JSR-303 (Bean Validation). The two annotations look alike but play different roles:
| Aspect | @Valid | @Validated |
|---|---|---|
| Origin | JSR-303 standard (jakarta.validation) | Spring-specific (org.springframework.validation.annotation) |
| Group validation | Not supported | Supports the groups attribute |
| Targets | Parameters, fields, methods | Classes, methods, parameters |
| Can validate method parameters | No | Yes (annotate the class to validate @RequestParam etc.) |
| Cascading | Supported (put @Valid on nested fields) | Supported |
One line to remember: use @Valid to validate a whole object, and @Validated when you need groups or to validate loose parameters.
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.
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) {}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.
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.
@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.
Loose parameters like @RequestParam and @PathVariable cannot take @Valid directly; you must annotate the class with @Validated:
@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); }}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.
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:
// 1) Two empty interfaces define the group markerspublic interface ValidGroups { interface Create {} interface Update {}}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) {}@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); }}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".

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.
When the built-ins cannot express a business rule, write your own. A phone check has two parts: the annotation and the validator.
@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;}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(); }}public record ProfileUpdateDTO( @NotBlank String nickname, @Phone(required = true, message = "please provide a valid phone number") String mobile) {}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.
Hard-coded text serves one language only. Pull the messages into resource bundles and multiple languages come for free:
# 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=手机号格式不正确Point at the bundle and its encoding:
spring: messages: basename: messages encoding: UTF-8With 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.
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".
@RestControllerAdvice + @ExceptionHandler is the only proper answer. Here is a complete implementation you can copy:
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) {}}Key points:
- Handler methods match by specificity — the
BizExceptionhandler is more specific than theExceptionfallback, so a business exception is always caught by it first - Every handler pins the HTTP status with
@ResponseStatusorResponseEntity, instead of always returning 200 - The fallback
Exceptionhandler is a net, not a bin: its job is to miss nothing, not to swallow anything —log.errorthe full stack and return only the traceId to the user
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:
{ "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" } ]}code: the business error code, which the front end can act on (e.g.40100redirects to login)message: a user-facing fallback sentencedata: a field-error list on validation failure, business data on success. Carried by theTtype, 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

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:
create(dto); // the method body never executes// DispatcherServlet.doDispatch(...) catches the exception just thrownprocessDispatchResult(request, response, mv, exception);// iterate handlerExceptionResolvers, order fixed by the containermv = resolver.resolveException(request, response, handler, exception);// ExceptionHandlerExceptionResolver: look for an @ExceptionHandler methodmapped = findMethod(exception); // closest type winsreturn new ResponseEntity<>(body, HttpStatus.BAD_REQUEST);| exception type | MethodArgumentNotValidException |
| offending argument | argument [0] |
| method body executed | no |

- @Valid on a primitive parameter does nothing:
@Valid @RequestParam String nametriggers no validation —@Validonly means "start validating an object / cascade", it cannot add a constraint to a string. For loose parameters, put@Validatedon the class and write@NotBlankat the parameter - Validation exceptions swallowed by the fallback: if you only write
@ExceptionHandler(Exception.class),MethodArgumentNotValidExceptiongets taken by it during matching (it is anExceptiontoo), 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
Figure 1 gives the whole pipeline view:

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.
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:
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:
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:
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:
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:
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:

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:
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.
"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:
No matching result
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.
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.
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:
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]]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().
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:
Everything worked locally; after one DTO field changed type the app fails on startup and the client only ever sees a 500.
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:

| Error text (fragment) | Real cause | 30-second fix | Dig deeper |
|---|---|---|---|
Validation failed for argument [0] ... [Field error in object 'xxx' on field 'username': rejected value []] | @Valid @RequestBody validation failed and the framework threw MethodArgumentNotValidException | This 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 switch | Add @Valid before @RequestBody; for loose parameters put @Validated on the class | Sections 2 and 4 |
jakarta.validation.ConstraintViolationException: detail.arg1: page starts at 1 | Class-level @Validated validating method parameters throws ConstraintViolationException, not MethodArgumentNotValidException | Cover both in the advice; read fields by iterating e.getConstraintViolations() and getPropertyPath() | Section 4 |
MethodArgumentNotValidException logged but the client sees 500 | Only @ExceptionHandler(Exception.class) exists, so the broadest handler wins the matching round | Give validation exceptions their own handler; reserve the fallback for genuine surprises | Section 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 Integer | Use @NotNull / @Min for numbers and @NotEmpty for collections | Section 3 |
Unable to create a Configuration, because no Bean Validation provider could be found | Spring Boot moved validation out of spring-boot-starter-web since 2.3 and the starter is missing | Add the spring-boot-starter-validation dependency | Section 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 validation | Handle it separately and return 400 saying "wrong field type"; validation annotations cannot help here | Section 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 path | Add @ExceptionHandler(BindException.class) sharing the same field-mapping helper | Section 8 |
| Opening the endpoint in a browser returns a plain HTML error page, and none of your business logs appear | Nothing matched any @ExceptionHandler, so the request was forwarded to /error (BasicErrorController) which rendered its default page | Write a @RestControllerAdvice fallback; while debugging set server.error.include-message=always and server.error.include-stacktrace=always, then turn them off again | End of this section + Section 13 sandbox |
| Group validation that worked yesterday suddenly stops applying | You switched to @Validated(Create.class), and constraints without a groups attribute belong to the Default group, which is now skipped | Add the group explicitly, or let the group marker interface extends Default | Section 5 |
Startup fails with must define the following attributes: [message, groups, payload] for your custom @Phone | The annotation misses the three members the spec requires, or @Constraint(validatedBy = ...) does not point at your validator | Add message() / groups() / payload() and check the validatedBy target | Section 6 |
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.
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:
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 }
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.
Build an endpoint that really returns field errors, end to end. Step one is the dependency (mandatory since Boot 2.3):
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId></dependency>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) {}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(); }}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; }}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".
Send one good request and two bad ones:
curl -i -X POST http://localhost:8080/api/signup \ -H "Content-Type: application/json" \ -d '{"username":"","email":"not-an-email","age":17}'Expected response (status line and body aligned word for word):
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"}]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).
Goal: three one-line edits, three completely different failure shapes.
- Delete
@Validfrom thecreateparameter, change nothing else, resend the same curl. You will observe HTTP 200, an empty username afterok:, and not even a warning in the log — a silent skip, which is worse than an error. - Change the first handler's parameter type in
ApiErrorsfromMethodArgumentNotValidExceptiontoException, restore@Valid, resend. You will observe the status becoming 500, the body becoming Boot's default{timestamp,status,error,path}, and anERRORstack appearing in the log — the fallback stole the validation exception. - Also delete the class-level
@Validated, then request/api/signup/-1. You will observe noConstraintViolationExceptionat all, just a cleandetail:-1— loose parameters are validated only because of the class-level@Validated; a lone@Positiveon the parameter does nothing.
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.
Acceptance checklist:
- [ ] Validation on the nested
AddressDTOand onList<OrderItemDTO>actually fires (send one illegal element and confirm a field name with an index, likeitems[1].skuId, appears in the response) - [ ]
createusesValidGroups.CreateandupdateusesValidGroups.Update, while shared rules such as@NotBlankapply under both groups (via explicitgroupsor by letting the marker interface extendDefault) - [ ] 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 theExceptionfallback - [ ] 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) /409or a business code (insufficient stock) /500(throw a RuntimeException deliberately) - [ ] You can explain why validation failures should be logged at
warn, noterror
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.
Who throws MethodArgumentNotValidException and who throws ConstraintViolationException? Which accessor reads the field errors in each case (getBindingResult().getFieldErrors() versus getConstraintViolations())?
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".
How do @NotBlank / @NotEmpty / @NotNull each treat " " (whitespace only), and which single one rejects it?
Facing a plain HTML error page, can you immediately say "no HandlerExceptionResolver took this"? Which two places in your code do you check next?
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.
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.