Why this matters
- Scattered try-catch in controllers duplicates error handling and produces inconsistent JSON error shapes.
- RFC 7807 Problem Details give clients a standard error format with type, title, status, and detail.
- Global exception handling is a production requirement — unhandled exceptions should never leak stack traces.
Domain exceptions
public class BookNotFoundException extends RuntimeException {
public BookNotFoundException(Long id) {
super("Book not found with id: " + id);
}
}
public class DuplicateIsbnException extends RuntimeException {
public DuplicateIsbnException(String isbn) {
super("Book with ISBN " + isbn + " already exists");
}
}
Services throw domain exceptions; controllers stay clean.
Global exception handler
@RestControllerAdvice
public class BookStoreExceptionHandler {
@ExceptionHandler(BookNotFoundException.class)
public ProblemDetail handleNotFound(BookNotFoundException ex) {
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
HttpStatus.NOT_FOUND, ex.getMessage());
problem.setTitle("Book Not Found");
problem.setType(URI.create("https://bookstore.example.com/errors/not-found"));
return problem;
}
@ExceptionHandler(DuplicateIsbnException.class)
public ProblemDetail handleDuplicate(DuplicateIsbnException ex) {
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
HttpStatus.CONFLICT, ex.getMessage());
problem.setTitle("Duplicate ISBN");
return problem;
}
}
ProblemDetail (Spring 6+) implements RFC 7807 natively.
Controller throwsBookNotFoundException
@ControllerAdviceGlobalExceptionHandler
ProblemDetailRFC 7807 JSON response
Validation errors
@ExceptionHandler(MethodArgumentNotValidException.class)
public ProblemDetail handleValidation(MethodArgumentNotValidException ex) {
ProblemDetail problem = ProblemDetail.forStatus(HttpStatus.BAD_REQUEST);
problem.setTitle("Validation Failed");
Map<String, String> errors = ex.getBindingResult().getFieldErrors().stream()
.collect(Collectors.toMap(
FieldError::getField,
FieldError::getDefaultMessage,
(a, b) -> a));
problem.setProperty("errors", errors);
return problem;
}
Clients receive field-level errors:
{
"type": "about:blank",
"title": "Validation Failed",
"status": 400,
"errors": {
"isbn": "ISBN must be 13 digits",
"price": "must be greater than 0"
}
}
Catch-all handler
@ExceptionHandler(Exception.class)
public ProblemDetail handleUnexpected(Exception ex) {
log.error("Unhandled exception", ex);
ProblemDetail problem = ProblemDetail.forStatus(HttpStatus.INTERNAL_SERVER_ERROR);
problem.setTitle("Internal Server Error");
problem.setDetail("An unexpected error occurred");
return problem;
}
Never include stack traces in production responses. Log them server-side.
HTTP status mapping for BookStore
- 400 Bad Request — Validation failures, malformed JSON.
- 404 Not Found —
BookNotFoundException, unknown URL. - 409 Conflict —
DuplicateIsbnException, optimistic lock failure. - 500 Internal Server Error — Unexpected exceptions (logged, not detailed).
Controller stays clean
@GetMapping("/{id}")
public BookResponse get(@PathVariable Long id) {
return BookResponse.from(bookService.findById(id));
// BookNotFoundException propagates to @RestControllerAdvice
}
No try-catch needed. The advice converts exceptions to HTTP responses automatically.
ResponseEntity alternative
For per-endpoint control without global advice:
@GetMapping("/{id}")
public ResponseEntity<BookResponse> get(@PathVariable Long id) {
try {
return ResponseEntity.ok(BookResponse.from(bookService.findById(id)));
} catch (BookNotFoundException ex) {
return ResponseEntity.notFound().build();
}
}
Prefer @RestControllerAdvice — one place to maintain error contracts.
Testing exception handling
@WebMvcTest(BookController.class)
class BookControllerExceptionTest {
@Autowired MockMvc mockMvc;
@MockBean BookService bookService;
@Test
void notFoundReturns404() throws Exception {
when(bookService.findById(99L)).thenThrow(new BookNotFoundException(99L));
mockMvc.perform(get("/api/books/99"))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.title").value("Book Not Found"));
}
}
Quick recall
Everything you need if you only revisit this box.
@RestControllerAdviceapplies exception handlers across all controllers.- Domain exceptions (
BookNotFoundException) carry business meaning; the advice maps them to HTTP status. ProblemDetailprovides RFC 7807 structured error responses.MethodArgumentNotValidExceptionhandler returns field-level validation errors.- Controllers stay free of try-catch — exceptions propagate to the advice.
- Log unexpected exceptions server-side; never expose stack traces to clients.
Test yourself
Answer these before moving on — recall is what makes it stick.