Stop returning 500s with stack traces. Here's a clean, centralized exception handling setup using @ControllerAdvice, custom exceptions, and structured error responses.
Most Spring Boot apps I've reviewed have exception handling scattered across controllers or, worse, none at all — just raw stack traces going to clients. Here's the centralized setup I use on every project.
1// Base exception
2public class AppException extends RuntimeException {
3 private final HttpStatus status;
4 private final String errorCode;
5
6 public AppException(String message, HttpStatus status, String errorCode) {
7 super(message);
8 this.status = status;
9 this.errorCode = errorCode;
10 }
11}
12
13// Specific exceptions
14public class ResourceNotFoundException extends AppException {
15 public ResourceNotFoundException(String resource, Long id) {
16 super(resource + " not found with id: " + id,
17 HttpStatus.NOT_FOUND, "RESOURCE_NOT_FOUND");
18 }
19}
20
21public class DuplicateResourceException extends AppException {
22 public DuplicateResourceException(String message) {
23 super(message, HttpStatus.CONFLICT, "DUPLICATE_RESOURCE");
24 }
25}1@RestControllerAdvice
2public class GlobalExceptionHandler {
3
4 @ExceptionHandler(AppException.class)
5 public ResponseEntity<ErrorResponse> handleAppException(AppException ex) {
6 return ResponseEntity.status(ex.getStatus())
7 .body(ErrorResponse.of(ex.getErrorCode(), ex.getMessage()));
8 }
9
10 @ExceptionHandler(MethodArgumentNotValidException.class)
11 public ResponseEntity<ErrorResponse> handleValidation(MethodArgumentNotValidException ex) {
12 List<String> errors = ex.getBindingResult().getFieldErrors()
13 .stream()
14 .map(e -> e.getField() + ": " + e.getDefaultMessage())
15 .toList();
16 return ResponseEntity.badRequest()
17 .body(ErrorResponse.of("VALIDATION_ERROR", String.join(", ", errors)));
18 }
19
20 @ExceptionHandler(Exception.class)
21 public ResponseEntity<ErrorResponse> handleGeneric(Exception ex) {
22 log.error("Unhandled exception", ex);
23 return ResponseEntity.internalServerError()
24 .body(ErrorResponse.of("INTERNAL_ERROR", "An unexpected error occurred"));
25 }
26}Never expose stack traces or internal exception messages to clients in production. Log them server-side with a correlation ID and return only the correlation ID in the error response so you can trace the issue without leaking internals.
1@Builder
2public record ErrorResponse(
3 String errorCode,
4 String message,
5 String correlationId,
6 LocalDateTime timestamp,
7 List<String> details
8) {
9 public static ErrorResponse of(String code, String msg) {
10 return ErrorResponse.builder()
11 .errorCode(code)
12 .message(msg)
13 .correlationId(MDC.get("correlationId"))
14 .timestamp(LocalDateTime.now())
15 .build();
16 }
17}
18
19// Response body for validation error:
20// {
21// "errorCode": "VALIDATION_ERROR",
22// "message": "email: must be a well-formed email address",
23// "correlationId": "req-7f3a2b1c",
24// "timestamp": "2025-06-10T12:34:56"
25// }1@Component
2public class CorrelationFilter extends OncePerRequestFilter {
3
4 @Override
5 protected void doFilterInternal(HttpServletRequest req,
6 HttpServletResponse res,
7 FilterChain chain) throws ServletException, IOException {
8 String correlationId = Optional
9 .ofNullable(req.getHeader("X-Correlation-ID"))
10 .orElse(UUID.randomUUID().toString().substring(0, 8));
11
12 MDC.put("correlationId", correlationId);
13 res.setHeader("X-Correlation-ID", correlationId);
14 try {
15 chain.doFilter(req, res);
16 } finally {
17 MDC.clear(); // prevent thread pool leaks
18 }
19 }
20}With MDC, every log line for a request automatically includes the correlationId. When a client reports an error, they give you the correlation ID from the response header, and you can grep your logs for all events in that request's lifecycle.
More in Backend Engineering