Exception Handling in Spring Boot Without the Boilerplate
Most Spring Boot codebases start out handling errors inside the controller. A try/catch here, an if (x == null) return ResponseEntity.notFound().build() there. It works, and then six months later every endpoint returns a slightly different error shape and the frontend team has stopped asking politely.
The fix is to stop handling errors in controllers at all.
Throw meaningful exceptions
Start by giving your domain a vocabulary. These are plain unchecked exceptions, and they carry no HTTP concepts at all:
public class OrderNotFoundException extends RuntimeException {
public OrderNotFoundException(long id) {
super("No order with id " + id);
}
}
The service throws it and moves on:
@Service
public class OrderService {
public Order findById(long id) {
return repository.findById(id)
.orElseThrow(() -> new OrderNotFoundException(id));
}
}
Notice what the service does not do: it does not know it is being called over HTTP. That matters the day the same service is called from a scheduled job or a message listener, where a 404 means nothing.
Translate in one place
@RestControllerAdvice registers a class whose @ExceptionHandler methods apply across every controller in the application:
@RestControllerAdvice
public class ApiExceptionHandler {
@ExceptionHandler(OrderNotFoundException.class)
public ProblemDetail handleNotFound(OrderNotFoundException ex) {
return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
}
}
ProblemDetail arrived in Spring Framework 6 and implements RFC 7807, the standard error body for HTTP APIs. Using it means you are not inventing a private error format:
{
"type": "about:blank",
"title": "Not Found",
"status": 404,
"detail": "No order with id 42"
}
You can add your own fields when the client genuinely needs them:
@ExceptionHandler(InsufficientFundsException.class)
public ProblemDetail handleFunds(InsufficientFundsException ex) {
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
HttpStatus.CONFLICT, "Balance is too low for this transfer");
problem.setTitle("Insufficient funds");
problem.setProperty("shortfall", ex.getShortfall());
return problem;
}
Validation errors need their own handler
Bean Validation failures do not arrive as your exceptions, they arrive as MethodArgumentNotValidException, and the default response tells the client almost nothing useful. Flattening the field errors is worth the ten lines:
@ExceptionHandler(MethodArgumentNotValidException.class)
public ProblemDetail handleInvalid(MethodArgumentNotValidException ex) {
Map<String, String> errors = ex.getBindingResult().getFieldErrors().stream()
.collect(Collectors.toMap(
FieldError::getField,
error -> Objects.requireNonNullElse(error.getDefaultMessage(), "invalid"),
(first, second) -> first));
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
HttpStatus.BAD_REQUEST, "Request body failed validation");
problem.setProperty("errors", errors);
return problem;
}
The merge function in that toMap is not decoration. Two constraints on the same field produce two FieldError entries, and without it toMap throws an IllegalStateException from inside your error handler, which is a memorable afternoon.
The catch-all, and what not to put in it
You want a final handler so that an unexpected failure still produces a clean body rather than a stack trace:
@ExceptionHandler(Exception.class)
public ProblemDetail handleUnexpected(Exception ex) {
log.error("Unhandled exception", ex);
return ProblemDetail.forStatusAndDetail(
HttpStatus.INTERNAL_SERVER_ERROR, "Something went wrong");
}
Two rules here. Log the exception, because this is the last place that still has it. And do not put ex.getMessage() in the response, because the message on an unexpected exception is written by whatever library threw it, and it happily leaks table names, file paths and connection strings.
Ordering
When an exception matches more than one handler, Spring picks the most specific type, so a handler for Exception never shadows one for OrderNotFoundException. What it does not do is guess between two handlers at the same distance, which is one more reason to keep this in a single class where you can see the whole set.
Where this leaves the controller
@GetMapping("/orders/{id}")
public OrderResponse get(@PathVariable long id) {
return OrderResponse.from(service.findById(id));
}
One line, no error handling, and the same 404 body as every other endpoint in the application. That consistency is the entire point: clients get one error format to code against, and you get one file to change when it needs to evolve.