All Posts
spring-bootPart 10 of java-basics-to-advanced

Spring Boot #2 — Validation & Exception Handling

Bean Validation annotations, @ControllerAdvice for global exception handling, and returning clean consistent error responses.

R
by Rupa
Feb 7, 20254 min read

Why Exception Handling Matters

Without proper error handling, Spring Boot returns its default whitelabel error page (useless) or a stack trace dump (dangerous in production). You want consistent, structured error responses like this:

{
  "status": 404,
  "error": "NOT_FOUND",
  "message": "Product with id 42 was not found",
  "timestamp": "2025-02-07T10:15:30"
}

Custom Exceptions

// Base exception
public class AppException extends RuntimeException {
    private final HttpStatus status;

    public AppException(String message, HttpStatus status) {
        super(message);
        this.status = status;
    }

    public HttpStatus getStatus() { return status; }
}

// Specific exceptions
public class ResourceNotFoundException extends AppException {
    public ResourceNotFoundException(String resource, Long id) {
        super(resource + " with id " + id + " was not found", HttpStatus.NOT_FOUND);
    }
}

public class DuplicateResourceException extends AppException {
    public DuplicateResourceException(String message) {
        super(message, HttpStatus.CONFLICT);
    }
}

public class BadRequestException extends AppException {
    public BadRequestException(String message) {
        super(message, HttpStatus.BAD_REQUEST);
    }
}

The Error Response DTO

public record ErrorResponse(
    int status,
    String error,
    String message,
    LocalDateTime timestamp
) {
    public static ErrorResponse of(HttpStatus status, String message) {
        return new ErrorResponse(
            status.value(),
            status.name(),
            message,
            LocalDateTime.now()
        );
    }
}

@ControllerAdvice — Global Exception Handler

@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {

    // Handle our custom exceptions
    @ExceptionHandler(AppException.class)
    public ResponseEntity<ErrorResponse> handleAppException(AppException ex) {
        log.warn("Application exception: {}", ex.getMessage());
        return ResponseEntity
            .status(ex.getStatus())
            .body(ErrorResponse.of(ex.getStatus(), ex.getMessage()));
    }

    // Handle validation errors
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ErrorResponse> handleValidationErrors(
            MethodArgumentNotValidException ex) {
        String message = ex.getBindingResult()
            .getFieldErrors()
            .stream()
            .map(err -> err.getField() + ": " + err.getDefaultMessage())
            .collect(Collectors.joining(", "));

        return ResponseEntity
            .status(HttpStatus.BAD_REQUEST)
            .body(ErrorResponse.of(HttpStatus.BAD_REQUEST, message));
    }

    // Handle path variable type mismatch (e.g. /products/abc)
    @ExceptionHandler(MethodArgumentTypeMismatchException.class)
    public ResponseEntity<ErrorResponse> handleTypeMismatch(
            MethodArgumentTypeMismatchException ex) {
        String message = "Invalid value '" + ex.getValue() + "' for parameter '" + ex.getName() + "'";
        return ResponseEntity
            .status(HttpStatus.BAD_REQUEST)
            .body(ErrorResponse.of(HttpStatus.BAD_REQUEST, message));
    }

    // Catch-all for unexpected exceptions
    @ExceptionHandler(Exception.class)
    public ResponseEntity<ErrorResponse> handleUnexpected(Exception ex) {
        log.error("Unexpected error", ex);
        return ResponseEntity
            .status(HttpStatus.INTERNAL_SERVER_ERROR)
            .body(ErrorResponse.of(HttpStatus.INTERNAL_SERVER_ERROR, "An unexpected error occurred"));
    }
}
Never expose stack traces in production

The catch-all handler logs the full exception but only returns a generic message to the client. Never serialize exception details (stack traces, internal class names) in API responses.

Bean Validation

Add the starter (included with spring-boot-starter-web):

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-validation</artifactId>
</dependency>

Annotate your DTO:

public record CreateProductRequest(
    @NotBlank(message = "Name is required")
    @Size(min = 2, max = 100, message = "Name must be between 2 and 100 characters")
    String name,

    @NotNull(message = "Price is required")
    @Positive(message = "Price must be positive")
    Double price,

    @Min(value = 0, message = "Stock cannot be negative")
    int stock,

    @Email(message = "Supplier email must be valid")
    String supplierEmail
) {}

Trigger validation with @Valid in your controller:

@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public ProductResponse create(@Valid @RequestBody CreateProductRequest request) {
    return productService.createProduct(request);
}

If validation fails, Spring throws MethodArgumentNotValidException — which our GlobalExceptionHandler catches and formats cleanly.

Common Validation Annotations

AnnotationWhat it checks
@NotNullNot null
@NotBlankNot null, not empty, not whitespace
@NotEmptyNot null, not empty (but whitespace is ok)
@Size(min, max)String/collection length
@Min(n) / @Max(n)Numeric range
@Positive / @PositiveOrZero> 0 or >= 0
@EmailValid email format
@Pattern(regexp)Matches regex
@Past / @FutureDate in past or future

Custom Validation Annotation

// 1. Define the annotation
@Target({ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = StrongPasswordValidator.class)
public @interface StrongPassword {
    String message() default "Password must be 8+ chars with upper, lower, digit, and symbol";
    Class<?>[] groups() default {};
    Class<? extends Payload>[] payload() default {};
}

// 2. Implement the validator
public class StrongPasswordValidator implements ConstraintValidator<StrongPassword, String> {
    private static final Pattern PATTERN = Pattern.compile(
        "^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)(?=.*[@$!%*?&])[A-Za-z\\d@$!%*?&]{8,}$"
    );

    @Override
    public boolean isValid(String value, ConstraintValidatorContext context) {
        return value != null && PATTERN.matcher(value).matches();
    }
}

// 3. Use it
public record RegisterRequest(
    @NotBlank String username,
    @StrongPassword String password
) {}

Updating the Service to Use Custom Exceptions

@Service
@RequiredArgsConstructor
public class ProductService {

    private final ProductRepository productRepository;

    public Product getProductById(Long id) {
        return productRepository.findById(id)
            .orElseThrow(() -> new ResourceNotFoundException("Product", id));
    }

    public Product createProduct(CreateProductRequest request) {
        if (productRepository.existsByName(request.name())) {
            throw new DuplicateResourceException("Product with name '" + request.name() + "' already exists");
        }
        Product product = new Product(null, request.name(), request.price(), request.stock());
        return productRepository.save(product);
    }
}
Separate request DTOs from entities

Never accept your JPA @Entity directly as a @RequestBody. Use a separate DTO. This prevents clients from setting internal fields like id, createdAt, or version.

What's Next?

Spring Boot #3 covers Spring Data JPA in depth — custom queries with JPQL and native SQL, pagination, sorting, and database migrations with Flyway.

#spring-boot#java#validation#error-handling

✦ Enjoyed this post?

Get posts like this in your inbox

No spam, just real tutorials when they're ready.

Discussion

Powered by GitHub

Comments use GitHub Discussions — no separate account needed if you have GitHub.