Backend EngineeringFebruary 10, 20255 min read

Spring Boot Exception Handling: The Right Way

Stop returning 500s with stack traces. Here's a clean, centralized exception handling setup using @ControllerAdvice, custom exceptions, and structured error responses.

JavaSpring BootREST APIsError HandlingBest Practices

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.

Custom Exception Hierarchy

AppException.java
java
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}

Global Exception Handler

GlobalExceptionHandler.java
java
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.

Structured Error Response

ErrorResponse.java
java
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// }

Correlation ID with MDC

CorrelationFilter.java
java
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