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.
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) {
}
| Annotation | Rule |
|---|---|
@NotBlank | not null and not just spaces (strings) |
@NotNull | not null (any type) |
@Size(min, max) | length of a string or collection |
@Min, @Max | number range |
@Email, @Pattern(regexp=…) | format |
@Past, @Future | dates |
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:
{
"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:
{
"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?
| Situation | Status |
|---|---|
| Malformed JSON or failed validation | 400 Bad Request |
| No / invalid login token | 401 Unauthorized |
| Logged in but not allowed | 403 Forbidden |
| Resource doesn't exist | 404 Not Found |
| Breaks a business rule (course full, duplicate) | 409 Conflict |
| Bug in your code | 500 — and log it; never show stack traces to clients |
Common mistakes
- Forgetting
@Valid— the annotations on the DTO are then ignored. @NotNullon a primitiveint(it can never be null) — use@Minor anInteger.- Catching
Exceptionin 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
@Validon the parameter. @RestControllerAdvice+@ExceptionHandlercentralise error handling.ProblemDetailgives standard JSON errors (400, 404, 409…).- Choose status codes that describe what went wrong.