Validation Error Handling

Validation error handling catches the exception that Spring throws when validation fails and turns it into a clear, structured response.It is used so the client gets “which field failed and why” instead of a long default Spring error or a server error. It is done with a @RestControllerAdvice class containing @ExceptionHandler methods, so one place handles errors for all controllers. Every error comes back in the same JSON shape, with a status, a message and an errors map of field: message.

Features

  • One global handler (GlobalExceptionHandler) for all controllers, so there is no try/catch in the controller.
  • Field-level messages, for example "age": "Age must be at least 18".
  • The same response format for every error.
  • Handles several kinds of failure, not only @Valid (see the table below).
  • Correct HTTP status codes: 400, 404, 409 and 500.
  • The generic 500 handler hides internal details from the client.

Validation Error Handling Types

Failure Exception caught Status Example
Body validation (@Valid @RequestBody) MethodArgumentNotValidException 400 Name too short, bad email
Parameter validation (@RequestParam, @PathVariable) ConstraintViolationException 400 /students/by-age?age=5
Unreadable or wrong-type JSON HttpMessageNotReadableException 400 "age": "twenty", broken JSON
Wrong type or missing value in the URL MethodArgumentTypeMismatchException, MissingServletRequestParameterException 400 /students/abc, no age param
Business-rule error (custom exception) DuplicateEmailException 409 Email already registered
Resource not found (custom exception) StudentNotFoundException 404 /students/99
Unexpected error Exception 500 Any bug, with a safe message

Hands-on Experiment: Creating a spring boot application to understand validation failures can be captured and presented as meaningful messages.

The following steps are involved in developing this hands-on experiment.

Step 1: Generate the Project

Step 2: Project Structure

Step 3: pom.xml

Step 4: application.properties

Step 5: Create Entity Class

Step 6: Create Repository Interface

Step 7: Create Service Class

Step 8: Create Controller Class

Step 9: Main Application Class

Step 10: Run the Main Application Class

Step 1: Configure the Project on Spring Initializr

Go to start.spring.io and set:

  • Project: Maven
  • Language: Java
  • Spring Boot version: 3.2.x
  • Group: com.example
  • Artifact: validation
  • Name: validation
  • Package name: com.example.validation
  • Packaging: Jar
  • Java: 17
  • Dependencies: Spring Web, Spring Data JPA, MySQL Driver, Thymeleaf, Validation I/O.

Click Generate to download the ZIP, then extract and import it into your IDE as a Maven project.

Step 2: Generated Project Structure

Step 3: Explore pom.xml

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.2.5</version>
        <relativePath/>
    </parent>

    <groupId>com.example</groupId>
    <artifactId>student-error-handling-demo</artifactId>
    <version>1.0.0</version>
    <name>student-error-handling-demo</name>

    <properties>
        <java.version>17</java.version>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-validation</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-jpa</artifactId>
        </dependency>
        <dependency>
            <groupId>com.mysql</groupId>
            <artifactId>mysql-connector-j</artifactId>
            <scope>runtime</scope>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>

Step 4: application.properties

spring.application.name=student-error-handling-demo
server.port=8082

spring.datasource.url=jdbc:mysql://localhost:3306/student_error_db?createDatabaseIfNotExist=true&useSSL=false&serverTimezone=UTC&allowPublicKeyRetrieval=true
spring.datasource.username=root
spring.datasource.password=password
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver

spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true
spring.jpa.database-platform=org.hibernate.dialect.MySQLDialect
Code language: JavaScript (javascript)

Step 5: Create Entity Class

package com.example.errorhandling.entity;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;

@Entity
@Table(name = "students")
public class Student {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @NotBlank(message = "Name is required")
    @Size(min = 3, max = 50, message = "Name must be between 3 and 50 characters")
    private String name;

    @NotBlank(message = "Email is required")
    @Email(message = "Enter a valid email address")
    @Column(unique = true)
    private String email;

    @NotNull(message = "Age is required")
    @Min(value = 18, message = "Age must be at least 18")
    @Max(value = 60, message = "Age must not be more than 60")
    private Integer age;

    @NotBlank(message = "Mobile number is required")
    @Pattern(regexp = "^[6-9][0-9]{9}$",
            message = "Mobile number must be 10 digits and start with 6, 7, 8 or 9")
    @Column(length = 10)
    private String mobile;

    @NotBlank(message = "Course is required")
    @Pattern(regexp = "Java|Spring Boot|SQL|React|Python",
            message = "Course must be one of: Java, Spring Boot, SQL, React, Python")
    private String course;

    public Student() {
    }

    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }

    public String getName() { return name; }
    public void setName(String name) { this.name = name; }

    public String getEmail() { return email; }
    public void setEmail(String email) { this.email = email; }

    public Integer getAge() { return age; }
    public void setAge(Integer age) { this.age = age; }

    public String getMobile() { return mobile; }
    public void setMobile(String mobile) { this.mobile = mobile; }

    public String getCourse() { return course; }
    public void setCourse(String course) { this.course = course; }
}

Step 6: Create below Repository Interface

package com.example.errorhandling.repository;

import com.example.errorhandling.entity.Student;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

import java.util.List;

@Repository
public interface StudentRepository extends JpaRepository<Student, Long> {

    boolean existsByEmail(String email);

    List<Student> findByAge(Integer age);
}

Step 7: Create below Service class

package com.example.errorhandling.service;

import com.example.errorhandling.entity.Student;
import com.example.errorhandling.repository.StudentRepository;
import org.springframework.stereotype.Service;

import java.util.List;

@Service
public class StudentService {

    private final StudentRepository studentRepository;

    public StudentService(StudentRepository studentRepository) {
        this.studentRepository = studentRepository;
    }

    public Student register(Student student) {
        if (studentRepository.existsByEmail(student.getEmail())) {
            throw new DuplicateEmailException(student.getEmail());
        }
        student.setId(null);   // a client must never choose the database id
        return studentRepository.save(student);
    }

    public List<Student> getAll() {
        return studentRepository.findAll();
    }

    public Student getById(Long id) {
        return studentRepository.findById(id).orElseThrow(() -> new StudentNotFoundException(id));
    }

    public List<Student> getByAge(Integer age) {
        return studentRepository.findByAge(age);
    }
}
package com.example.errorhandling.service;

// Business-rule failure: the email is already used by another student
public class DuplicateEmailException extends RuntimeException {

    private final String email;

    public DuplicateEmailException(String email) {
        super("Email already registered: " + email);
        this.email = email;
    }

    public String getEmail() {
        return email;
    }
}
package com.example.errorhandling.service;

// Thrown when a student id does not exist
public class StudentNotFoundException extends RuntimeException {

    public StudentNotFoundException(Long id) {
        super("Student not found with id " + id);
    }
}

Step 8: Create Controller class

package com.example.errorhandling.controller;

import com.example.errorhandling.entity.Student;
import com.example.errorhandling.service.StudentService;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import java.util.List;

@RestController
@RequestMapping("/students")
@Validated   // needed so rules on @RequestParam / @PathVariable are checked too
public class StudentController {

    private final StudentService studentService;

    public StudentController(StudentService studentService) {
        this.studentService = studentService;
    }

    // POST /students   (body is validated; failures go to GlobalExceptionHandler)
    @PostMapping
    public ResponseEntity<Student> register(@Valid @RequestBody Student student) {
        return ResponseEntity.status(HttpStatus.CREATED).body(studentService.register(student));
    }

    // GET /students
    @GetMapping
    public List<Student> getAll() {
        return studentService.getAll();
    }

    // GET /students/by-age?age=22   (request parameter is validated)
    @GetMapping("/by-age")
    public List<Student> getByAge(
            @RequestParam
            @Min(value = 18, message = "Age must be at least 18")
            @Max(value = 60, message = "Age must not be more than 60") Integer age) {
        return studentService.getByAge(age);
    }

    // GET /students/1
    @GetMapping("/{id}")
    public Student getById(@PathVariable Long id) {
        return studentService.getById(id);
    }
}
package com.example.errorhandling.controller;

import com.example.errorhandling.service.DuplicateEmailException;
import com.example.errorhandling.service.StudentNotFoundException;
import com.fasterxml.jackson.databind.exc.InvalidFormatException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.ConstraintViolation;
import jakarta.validation.ConstraintViolationException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.MissingServletRequestParameterException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;

import java.time.LocalDateTime;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.TreeMap;

// Catches exceptions from every controller and turns them into one consistent JSON shape:
// { timestamp, status, error, message, path, errors: { field: message } }
@RestControllerAdvice
public class GlobalExceptionHandler {

    // 1. @Valid @RequestBody failed -> field-level errors
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<Map<String, Object>> handleBodyValidation(MethodArgumentNotValidException ex,
                                                                    HttpServletRequest request) {
        Map<String, String> errors = new TreeMap<>();
        ex.getBindingResult().getFieldErrors().forEach(fieldError ->
                errors.merge(fieldError.getField(), fieldError.getDefaultMessage(),
                        (first, second) -> first + "; " + second));
        return build(HttpStatus.BAD_REQUEST, "Validation Failed",
                "Input validation failed for " + errors.size() + " field(s)", request, errors);
    }

    // 2. Rules on @RequestParam / @PathVariable failed
    @ExceptionHandler(ConstraintViolationException.class)
    public ResponseEntity<Map<String, Object>> handleParamValidation(ConstraintViolationException ex,
                                                                     HttpServletRequest request) {
        Map<String, String> errors = new TreeMap<>();
        for (ConstraintViolation<?> violation : ex.getConstraintViolations()) {
            String path = violation.getPropertyPath().toString();            // e.g. getByAge.age
            String field = path.substring(path.lastIndexOf('.') + 1);        // -> age
            errors.merge(field, violation.getMessage(), (first, second) -> first + "; " + second);
        }
        return build(HttpStatus.BAD_REQUEST, "Validation Failed",
                "Input validation failed for " + errors.size() + " field(s)", request, errors);
    }

    // 3. Broken JSON, or a value of the wrong type (for example "age": "abc")
    @ExceptionHandler(HttpMessageNotReadableException.class)
    public ResponseEntity<Map<String, Object>> handleUnreadableBody(HttpMessageNotReadableException ex,
                                                                    HttpServletRequest request) {
        Map<String, String> errors = new TreeMap<>();
        String message = "Malformed JSON request";
        if (ex.getCause() instanceof InvalidFormatException invalid && !invalid.getPath().isEmpty()) {
            String field = invalid.getPath().get(invalid.getPath().size() - 1).getFieldName();
            if (field != null) {
                errors.put(field, "Invalid value '" + invalid.getValue()
                        + "'. Expected type: " + invalid.getTargetType().getSimpleName());
                message = "Invalid value for field '" + field + "'";
            }
        }
        return build(HttpStatus.BAD_REQUEST, "Bad Request", message, request, errors);
    }

    // 4. URL value of the wrong type (for example /students/abc or ?age=abc)
    @ExceptionHandler(MethodArgumentTypeMismatchException.class)
    public ResponseEntity<Map<String, Object>> handleTypeMismatch(MethodArgumentTypeMismatchException ex,
                                                                  HttpServletRequest request) {
        String type = ex.getRequiredType() == null ? "valid value" : ex.getRequiredType().getSimpleName();
        Map<String, String> errors = new TreeMap<>();
        errors.put(ex.getName(), "Invalid value '" + ex.getValue() + "'. Expected type: " + type);
        return build(HttpStatus.BAD_REQUEST, "Bad Request",
                "Invalid value for parameter '" + ex.getName() + "'", request, errors);
    }

    // 5. A required request parameter is missing
    @ExceptionHandler(MissingServletRequestParameterException.class)
    public ResponseEntity<Map<String, Object>> handleMissingParam(MissingServletRequestParameterException ex,
                                                                  HttpServletRequest request) {
        Map<String, String> errors = new TreeMap<>();
        errors.put(ex.getParameterName(), "Parameter is required");
        return build(HttpStatus.BAD_REQUEST, "Bad Request",
                "Missing parameter '" + ex.getParameterName() + "'", request, errors);
    }

    // 6. Business rule: email already registered -> field-level error on "email"
    @ExceptionHandler(DuplicateEmailException.class)
    public ResponseEntity<Map<String, Object>> handleDuplicateEmail(DuplicateEmailException ex,
                                                                    HttpServletRequest request) {
        Map<String, String> errors = new TreeMap<>();
        errors.put("email", ex.getEmail() + " is already registered");
        return build(HttpStatus.CONFLICT, "Conflict", "Email already registered", request, errors);
    }

    // 7. Student id not found
    @ExceptionHandler(StudentNotFoundException.class)
    public ResponseEntity<Map<String, Object>> handleNotFound(StudentNotFoundException ex,
                                                              HttpServletRequest request) {
        return build(HttpStatus.NOT_FOUND, "Not Found", ex.getMessage(), request, null);
    }

    // 8. Anything unexpected: a safe message, no internal details leaked
    @ExceptionHandler(Exception.class)
    public ResponseEntity<Map<String, Object>> handleOthers(Exception ex, HttpServletRequest request) {
        return build(HttpStatus.INTERNAL_SERVER_ERROR, "Internal Server Error",
                "Something went wrong. Please try again later.", request, null);
    }

    private ResponseEntity<Map<String, Object>> build(HttpStatus status, String error, String message,
                                                      HttpServletRequest request, Map<String, String> errors) {
        Map<String, Object> body = new LinkedHashMap<>();
        body.put("timestamp", LocalDateTime.now().toString());
        body.put("status", status.value());
        body.put("error", error);
        body.put("message", message);
        body.put("path", request.getRequestURI());
        if (errors != null && !errors.isEmpty()) {
            body.put("errors", errors);
        }
        return ResponseEntity.status(status).body(body);
    }
}

Step 9: Main Application

package com.example.errorhandling;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class StudentErrorHandlingApplication {

    public static void main(String[] args) {
        SpringApplication.run(StudentErrorHandlingApplication.class, args);
    }
}

Step 10: Run the StudentErrorHandlingApplication

Scroll to Top