Validation and Exception Handling
Never trust input. Bean Validation checks request data with annotations, and a global exception handler turns every error into a clean, consistent JSON response.
Common validation annotations
| Annotation | Checks |
|---|---|
| @NotNull | Value is not null |
| @NotBlank | String is not null and has a non-space character |
| @NotEmpty | String, collection or array is not empty |
| @Size(min, max) | Length or size range |
| @Min / @Max | Number range |
| @Positive | Number > 0 |
| Valid email format | |
| @Pattern(regexp) | Matches a regular expression |
| @Past / @Future | Date in the past or future |
DTO with validation and controller
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>import jakarta.validation.Valid;
import jakarta.validation.constraints.*;
public record UserRequest(
@NotBlank(message = "name is required") @Size(max = 50) String name,
@NotBlank @Email(message = "email is not valid") String email,
@Min(value = 18, message = "age must be at least 18") int age) { }
@RestController
@RequestMapping("/api/users")
public class UserController {
@PostMapping
public ResponseEntity<String> create(@Valid @RequestBody UserRequest request) {
return ResponseEntity.status(201).body("Created " + request.name());
}
}Custom exception and global handler
public class NotFoundException extends RuntimeException {
public NotFoundException(String message) { super(message); }
}
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(NotFoundException.class)
public ProblemDetail handleNotFound(NotFoundException ex) {
ProblemDetail pd = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
pd.setTitle("Resource not found");
return pd;
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ProblemDetail handleValidation(MethodArgumentNotValidException ex) {
Map<String, String> errors = new LinkedHashMap<>();
ex.getBindingResult().getFieldErrors()
.forEach(e -> errors.put(e.getField(), e.getDefaultMessage()));
ProblemDetail pd = ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, "Validation failed");
pd.setProperty("errors", errors);
return pd;
}
@ExceptionHandler(Exception.class)
public ProblemDetail handleAny(Exception ex) {
// log the real error, but do not send internal details to the client
return ProblemDetail.forStatusAndDetail(HttpStatus.INTERNAL_SERVER_ERROR, "Something went wrong");
}
}A failed validation returns a response in the standard Problem Details format (RFC 9457):
{
"type": "about:blank",
"title": "Bad Request",
"status": 400,
"detail": "Validation failed",
"instance": "/api/users",
"errors": { "name": "name is required", "age": "age must be at least 18" }
}Validate path and query parameters
@RestController
@Validated
@RequestMapping("/api/items")
public class ItemController {
@GetMapping("/{id}")
public String get(@PathVariable @Min(1) Long id) { return "item " + id; }
}
// Invalid values raise ConstraintViolationException (or HandlerMethodValidationException in
// Spring 6.1+). Handle them in your @RestControllerAdvice too.Custom constraint
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = StrongPasswordValidator.class)
public @interface StrongPassword {
String message() default "password must have 8+ characters and a digit";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
public class StrongPasswordValidator implements ConstraintValidator<StrongPassword, String> {
@Override
public boolean isValid(String value, ConstraintValidatorContext ctx) {
return value != null && value.length() >= 8 && value.chars().anyMatch(Character::isDigit);
}
}Tip: Add the property spring.mvc.problemdetails.enabled=true to make Spring MVC return Problem Details for its own built-in exceptions as well.
Validation groups: different rules for create and update
public interface OnCreate { }
public interface OnUpdate { }
public record ProductDto(
@Null(groups = OnCreate.class) @NotNull(groups = OnUpdate.class) Long id, // id must be absent on create, present on update
@NotBlank(groups = { OnCreate.class, OnUpdate.class }) String name,
@Positive(groups = { OnCreate.class, OnUpdate.class }) BigDecimal price) { }
@PostMapping public ProductDto create(@Validated(OnCreate.class) @RequestBody ProductDto dto) { return dto; }
@PutMapping("/{id}") public ProductDto update(@PathVariable Long id, @Validated(OnUpdate.class) @RequestBody ProductDto dto) { return dto; }Cross-field validation with a class-level constraint
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = PasswordsMatchValidator.class)
public @interface PasswordsMatch {
String message() default "passwords do not match";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
public class PasswordsMatchValidator implements ConstraintValidator<PasswordsMatch, RegisterRequest> {
@Override
public boolean isValid(RegisterRequest r, ConstraintValidatorContext ctx) {
boolean ok = r.password() != null && r.password().equals(r.confirmPassword());
if (!ok) {
ctx.disableDefaultConstraintViolation();
ctx.buildConstraintViolationWithTemplate("passwords do not match")
.addPropertyNode("confirmPassword").addConstraintViolation(); // error is reported on this field
}
return ok;
}
}
@PasswordsMatch
public record RegisterRequest(@Email String email, @Size(min = 12) String password, String confirmPassword) { }Nested objects and collections
public record OrderRequest(
@NotBlank String customerId,
@Valid @NotNull Address shippingAddress, // @Valid is REQUIRED to validate the nested object
@NotEmpty @Size(max = 50) List<@Valid OrderLine> lines) { } // and each element of the list
public record OrderLine(@NotBlank String sku, @Min(1) @Max(100) int quantity) { }
public record Address(@NotBlank String street, @Pattern(regexp = "\\d{6}") String pincode) { }Messages and internationalization
# src/main/resources/ValidationMessages.properties
user.name.required=Name is required
user.age.min=Age must be at least {value}
# ValidationMessages_hi.properties for Hindi ... the Accept-Language header selects the filepublic record UserRequest(
@NotBlank(message = "{user.name.required}") String name,
@Min(value = 18, message = "{user.age.min}") int age) { }Validate programmatically and read all errors
@Service
class ImportService {
private final Validator validator; // jakarta.validation.Validator, a Spring bean
ImportService(Validator validator) { this.validator = validator; }
List<String> check(ProductDto dto) {
return validator.validate(dto).stream()
.map(v -> v.getPropertyPath() + " " + v.getMessage())
.sorted().toList(); // useful in batch imports: collect errors instead of failing on the first
}
}Order in which Spring resolves an exception
Controller throws exception
└─ HandlerExceptionResolverComposite tries in order:
1. ExceptionHandlerExceptionResolver @ExceptionHandler in the controller, then in @ControllerAdvice
2. ResponseStatusExceptionResolver @ResponseStatus on the exception class, or ResponseStatusException
3. DefaultHandlerExceptionResolver Spring MVC's own (HttpRequestMethodNotSupported -> 405, TypeMismatch -> 400 ...)
none handled ─► exception goes to the servlet container ─► /error (BasicErrorController) ─► JSON error body of Boot@ResponseStatus(HttpStatus.CONFLICT) // simplest way for a custom exception
public class DuplicateEmailException extends RuntimeException { }
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Product 5 not found"); // ad-hoc, no class neededCommon mistake: Catching every exception in each controller method with try-catch. Use one @RestControllerAdvice so error handling is in one place and responses are consistent.
Interview questions
@Valid or @Validated?
@Valid (Jakarta) validates an object and nested objects. @Validated (Spring) also supports validation groups and method-level validation on beans.
How do I avoid leaking stack traces?
Return a short message from a global handler, log the details on the server, and keep server.error.include-stacktrace=never.
What is ProblemDetail?
A Spring 6 class for the standard RFC 9457 error format (type, title, status, detail, instance).
Why are my nested object fields not validated?
You must put @Valid on the nested field or collection element. Bean Validation does not cascade by default.
Where does Boot's default JSON error body come from?
From BasicErrorController on /error, filled by DefaultErrorAttributes. Control what it shows with server.error.* properties or replace it with your own @RestControllerAdvice.