Skills101

Advanced Java & Spring Boot · Lesson 6 of 16 · Updated ·

Validation and Exception Handling — @Valid, @RestControllerAdvice and ProblemDetail

Never trust input. A mobile app might send an empty title, a negative fee or a missing field. Your API must reject it with a clear 400 Bad Request — and every error should look the same.

A request is checked by @Valid; valid data reaches the controller, invalid data and exceptions thrown by the service go to the @RestControllerAdvice, which returns ProblemDetail JSON

1. Bean Validation on the request DTO

// File: course-api/src/main/java/com/skills101/courses/course/CourseRequest.java
package com.skills101.courses.course;

import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;

// What a client sends to create or update a course
public record CourseRequest(
        @NotBlank @Size(max = 80) String title,
        @Min(0) @Max(100000) int fee,
        @Min(1) @Max(500) int seats) {
}
AnnotationRule
@NotBlanknot null and not just spaces (strings)
@NotNullnot null (any type)
@Size(min, max)length of a string or collection
@Min, @Maxnumber range
@Email, @Pattern(regexp=…)format
@Past, @Futuredates

The rules only run when the controller parameter has @Valid (see @Valid @RequestBody CourseRequest request in the controller). The starter is spring-boot-starter-validation.

2. One place for all errors

Our service throws plain exceptions:

// File: course-api/src/main/java/com/skills101/courses/course/CourseNotFoundException.java
package com.skills101.courses.course;

public class CourseNotFoundException extends RuntimeException {

    public CourseNotFoundException(Long id) {
        super("Course " + id + " not found");
    }
}

A @RestControllerAdvice class catches exceptions from every controller and turns them into responses:

// File: course-api/src/main/java/com/skills101/courses/common/ApiExceptionHandler.java
package com.skills101.courses.common;

import com.skills101.courses.course.CourseNotFoundException;
import com.skills101.courses.enrollment.EnrollmentException;
import java.util.Map;
import java.util.TreeMap;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

// One place that turns exceptions into clean JSON errors (RFC 9457 "problem details")
@RestControllerAdvice
public class ApiExceptionHandler {

    @ExceptionHandler(CourseNotFoundException.class)
    public ProblemDetail notFound(CourseNotFoundException e) {
        return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, e.getMessage());
    }

    @ExceptionHandler(EnrollmentException.class)
    public ProblemDetail conflict(EnrollmentException e) {
        return ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, e.getMessage());
    }

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ProblemDetail invalid(MethodArgumentNotValidException e) {
        ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, "Some fields are invalid");
        Map<String, String> errors = new TreeMap<>();   // sorted, so the order is predictable
        e.getBindingResult().getFieldErrors().forEach(f -> errors.put(f.getField(), f.getDefaultMessage()));
        problem.setProperty("errors", errors);
        return problem;
    }
}

ProblemDetail is Spring's built-in support for RFC 9457 "problem details" — a standard JSON error shape with title, status, detail and instance (the path), plus any extra properties you add.

3. What clients see

Invalid course ({"title":"","fee":-5,"seats":0}) → 400:

<!-- expect: course-api/validation-error.json -->
{
  "title": "Bad Request",
  "status": 400,
  "detail": "Some fields are invalid",
  "instance": "/api/courses",
  "errors": {
    "fee": "must be greater than or equal to 0",
    "seats": "must be greater than or equal to 1",
    "title": "must not be blank"
  }
}

Unknown course (GET /api/courses/99) → 404:

<!-- expect: course-api/course-not-found.json -->
{
  "title": "Not Found",
  "status": 404,
  "detail": "Course 99 not found",
  "instance": "/api/courses/99"
}

The content type is application/problem+json. Field errors are sorted (we used a TreeMap) so the output is predictable.

Spring Boot 4 / Spring 7 note: the default "type": "about:blank" is no longer written into the JSON. Many older tutorials still show it — our tests run on Boot 4.1, so the responses above are exactly what you'll get.

4. Which status for which error?

SituationStatus
Malformed JSON or failed validation400 Bad Request
No / invalid login token401 Unauthorized
Logged in but not allowed403 Forbidden
Resource doesn't exist404 Not Found
Breaks a business rule (course full, duplicate)409 Conflict
Bug in your code500 — and log it; never show stack traces to clients

Common mistakes

  • Forgetting @Valid — the annotations on the DTO are then ignored.
  • @NotNull on a primitive int (it can never be null) — use @Min or an Integer.
  • Catching Exception in every controller method instead of one advice class.
  • Returning error messages that reveal SQL or stack traces.

Quick recap

  • Put constraints on request DTOs; add @Valid on the parameter.
  • @RestControllerAdvice + @ExceptionHandler centralise error handling.
  • ProblemDetail gives standard JSON errors (400, 404, 409…).
  • Choose status codes that describe what went wrong.

Practice what you learned

📝 Quiz: Validation and Errors5 questions · instant answers & explanations · +10 XP · counts towards your free certificateTake the quiz →