Scattered try-catch blocks, inconsistent error formats, and stack traces leaking to clients are all fixable. Here's how to centralize exception handling in Spring Boot properly.
JOptimize Team
An API that returns 500 Internal Server Error with a raw Java stack trace is telling your clients two things: something broke, and you haven't thought carefully about your error handling. The stack trace is a security risk (it reveals implementation details), the status code is wrong (it might be a 400, not a 500), and the response format is inconsistent with everything else the API returns.
Good exception handling in Spring Boot is centralized, consistent, and informative without being dangerous. Here's how to build it.
The naive approach puts try-catch in every controller:
@GetMapping("/{id}") public ResponseEntity<OrderDto> getOrder(@PathVariable Long id) { try { Order order = orderService.findById(id); return ResponseEntity.ok(OrderMapper.toDto(order)); } catch (OrderNotFoundException e) { return ResponseEntity.notFound().build(); } catch (Exception e) { return ResponseEntity.internalServerError().build(); } }
This works for one endpoint, but it creates three problems at scale: the error handling logic is duplicated across every controller, the response format differs depending on which developer wrote the catch block, and adding a new exception type means updating every controller.
@ControllerAdvice defines global exception handlers that apply to all controllers:
@RestControllerAdvice @Slf4j public class GlobalExceptionHandler { // 404 — Resource not found @ExceptionHandler(ResourceNotFoundException.class) @ResponseStatus(HttpStatus.NOT_FOUND) public ProblemDetail handleNotFound(ResourceNotFoundException ex, HttpServletRequest request) { ProblemDetail problem = ProblemDetail.forStatusAndDetail( HttpStatus.NOT_FOUND, ex.getMessage()); problem.setTitle("Resource Not Found"); problem.setInstance(URI.create(request.getRequestURI())); return problem; } // 409 — Business rule violation @ExceptionHandler(BusinessException.class) @ResponseStatus(HttpStatus.CONFLICT) public ProblemDetail handleBusinessException(BusinessException ex, HttpServletRequest request) { ProblemDetail problem = ProblemDetail.forStatusAndDetail( HttpStatus.CONFLICT, ex.getMessage()); problem.setTitle("Business Rule Violation"); problem.setProperty("errorCode", ex.getErrorCode()); return problem; } // 400 — Validation errors @ExceptionHandler(MethodArgumentNotValidException.class) @ResponseStatus(HttpStatus.BAD_REQUEST) public ProblemDetail handleValidation(MethodArgumentNotValidException ex) { ProblemDetail problem = ProblemDetail.forStatus(HttpStatus.BAD_REQUEST); problem.setTitle("Validation Failed"); Map<String, String> errors = new LinkedHashMap<>(); ex.getBindingResult().getFieldErrors() .forEach(err -> errors.put(err.getField(), err.getDefaultMessage())); problem.setProperty("errors", errors); return problem; } // 500 — Unexpected errors (log fully, return minimal info) @ExceptionHandler(Exception.class) @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) public ProblemDetail handleUnexpected(Exception ex, HttpServletRequest request) { String errorId = UUID.randomUUID().toString(); log.error("Unexpected error [{}] on {}: {}", errorId, request.getRequestURI(), ex.getMessage(), ex); ProblemDetail problem = ProblemDetail.forStatus(HttpStatus.INTERNAL_SERVER_ERROR); problem.setTitle("Internal Server Error"); problem.setDetail("An unexpected error occurred. Reference: " + errorId); // Note: NO stack trace in the response — logged with errorId instead return problem; } }
Spring Boot 3.x supports ProblemDetail out of the box — it implements RFC 9457 (formerly RFC 7807), the internet standard for machine-readable HTTP error responses:
{ "type": "https://api.myapp.com/errors/validation-failed", "title": "Validation Failed", "status": 400, "detail": "Request body contains invalid fields", "instance": "/api/v1/orders", "errors": { "customerId": "must not be null", "total": "must be greater than 0" } }
The format is standardized, so API clients can handle it generically rather than parsing custom error structures per API.
# Enable ProblemDetail for Spring MVC exceptions automatically spring: mvc: problemdetails: enabled: true
With this flag, Spring automatically returns ProblemDetail for built-in exceptions like NoHandlerFoundException, MethodNotAllowedException, and HttpMessageNotReadableException.
Business exceptions should carry an error code so clients can handle specific cases programmatically:
// Base exception with error code public class BusinessException extends RuntimeException { private final String errorCode; public BusinessException(String errorCode, String message) { super(message); this.errorCode = errorCode; } public String getErrorCode() { return errorCode; } } // Specific exceptions — readable, discoverable public class OrderNotFoundException extends BusinessException { public OrderNotFoundException(Long orderId) { super("ORDER_NOT_FOUND", "Order not found: " + orderId); } } public class InsufficientStockException extends BusinessException { public InsufficientStockException(Long productId, int requested, int available) { super("INSUFFICIENT_STOCK", "Insufficient stock for product %d: requested %d, available %d" .formatted(productId, requested, available)); } } public class PaymentDeclinedException extends BusinessException { public PaymentDeclinedException(String reason) { super("PAYMENT_DECLINED", "Payment declined: " + reason); } }
In the service layer, just throw — no need for try-catch:
@Transactional public Order placeOrder(PlaceOrderRequest req) { Order order = orderRepo.findById(req.getOrderId()) .orElseThrow(() -> new OrderNotFoundException(req.getOrderId())); if (!inventoryService.hasStock(req.getProductId(), req.getQuantity())) { int available = inventoryService.getStock(req.getProductId()); throw new InsufficientStockException(req.getProductId(), req.getQuantity(), available); } // ... proceed with order }
// Request DTO with Bean Validation public record PlaceOrderRequest( @NotNull(message = "Customer ID is required") Long customerId, @NotEmpty(message = "Order must contain at least one item") List<@Valid OrderItemRequest> items, @NotNull @DecimalMin(value = "0.01", message = "Total must be positive") BigDecimal total, @Size(max = 500, message = "Notes cannot exceed 500 characters") String notes ) {} // Controller — no validation logic needed @PostMapping public ResponseEntity<OrderDto> createOrder( @RequestBody @Valid PlaceOrderRequest req) { // @Valid triggers Bean Validation // MethodArgumentNotValidException thrown automatically if invalid // GlobalExceptionHandler catches it and returns 400 with field errors return ResponseEntity.status(201).body(orderService.create(req)); }
Validation response example:
{ "title": "Validation Failed", "status": 400, "errors": { "customerId": "Customer ID is required", "items[0].quantity": "must be greater than 0" } }
The key principle: log everything, return the minimum necessary.
// For business exceptions (4xx) — warn level, no stack trace needed log.warn("Business rule violation: {} - {}", ex.getErrorCode(), ex.getMessage()); // For unexpected exceptions (5xx) — error level WITH stack trace // Include a correlation ID so you can find the log entry from the error ID log.error("Unexpected error [correlationId={}]: {}", correlationId, ex.getMessage(), ex); // NEVER return stack traces in API responses // They reveal: class names, package structure, framework versions, internal logic
@ControllerAdvice is easy to test; write tests that verify the correct status code and response body for each exception typeCentralize all exception handling in a @RestControllerAdvice class. Use ProblemDetail (RFC 9457) for a standardized, machine-readable error format. Define custom business exceptions with error codes. Log the full exception server-side; return only a reference ID to the client for unexpected errors. Keep controllers completely free of try-catch blocks.
JOptimize detects @Transactional methods that swallow exceptions, missing error handling patterns, and service methods that can throw unchecked exceptions without proper boundaries.
Handle errors well. Surprise no one.
Master Spring Boot, security, and Java performance with hands-on courses.
JOptimize finds N+1 queries, EAGER collections, and 70+ other issues in your Java codebase — in under 30 seconds.