Home / Spring Boot / Web and REST / Validation and Exception Handling

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

AnnotationChecks
@NotNullValue is not null
@NotBlankString is not null and has a non-space character
@NotEmptyString, collection or array is not empty
@Size(min, max)Length or size range
@Min / @MaxNumber range
@PositiveNumber > 0
@EmailValid email format
@Pattern(regexp)Matches a regular expression
@Past / @FutureDate 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 file
public 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 needed

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