Tutorial 10: Global Exception Handling
Custom exceptions, @RestControllerAdvice, and mapping exception types to the right HTTP status codes.
← Back to TutorialsWithout a global exception handler, any unhandled exception in a Spring Boot application results in an ugly 500 Internal Server Error with a stack trace. A well-structured exception handler turns domain failures into clean, consistent JSON error responses with the correct HTTP status code: making the API far easier to consume.
1. Custom exception classes
Each domain failure gets its own exception class. They all extend RuntimeException: unchecked exceptions that don't need to be declared in method signatures.
// Pattern for all custom exceptions in this project:
public class ActiveSessionExistsException extends RuntimeException {
public ActiveSessionExistsException(String message) {
super(message);
}
public ActiveSessionExistsException(String message, Throwable cause) {
super(message, cause);
}
}
// Other exceptions follow the same pattern:
// MenuItemIdNotFoundException, MenuItemNotFoundException, MenuItemNotAvailableException
// NoActiveTableSessionFoundException, NoActiveSessionsFoundExceptions
// NoTableSessionFoundException, TableSessionNotFound
// OrderNotFoundException, ResourceNotFoundException
- Each exception class is tiny: just a name and constructors. The name carries all the meaning; no logic needed.
- Two constructors: one for a message only, one for message + cause. The cause constructor lets you wrap lower-level exceptions without losing the original stack trace.
- Extending
RuntimeExceptionmeans callers don't need atry/catch: the exception bubbles up to the global handler automatically.
2. @RestControllerAdvice: the global handler
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(ActiveSessionExistsException.class)
public ResponseEntity<Map<String, Object>> handleActiveSessionExists(
ActiveSessionExistsException ex) {
Map<String, Object> error = new HashMap<>();
error.put("status", HttpStatus.CONFLICT.value()); // 409
error.put("error", "Active Session Exists");
error.put("message", ex.getMessage());
error.put("timestamp", Instant.now());
return new ResponseEntity<>(error, HttpStatus.CONFLICT);
}
@ExceptionHandler(TableSessionNotFound.class)
public ResponseEntity<Map<String, Object>> handleSessionNotFound(TableSessionNotFound ex) {
Map<String, Object> error = new HashMap<>();
error.put("status", HttpStatus.NOT_FOUND.value()); // 404
error.put("error", "Not Found");
error.put("message", "Table Session Not Found");
error.put("timestamp", Instant.now());
return new ResponseEntity<>(error, HttpStatus.NOT_FOUND);
}
@ExceptionHandler(Exception.class)
public ResponseEntity<Map<String, Object>> handleAllOtherExceptions(Exception ex) {
Map<String, Object> error = new HashMap<>();
error.put("status", HttpStatus.INTERNAL_SERVER_ERROR.value()); // 500
error.put("error", "Internal Server Error");
error.put("message", ex.getMessage());
error.put("timestamp", Instant.now());
return new ResponseEntity<>(error, HttpStatus.INTERNAL_SERVER_ERROR);
}
}
- @RestControllerAdvice combines
@ControllerAdviceand@ResponseBody. Every@ExceptionHandlerreturn value is serialized to JSON automatically. - @ExceptionHandler(SomeException.class) registers a method as the handler for that specific exception type. Spring matches the most specific handler first.
- The catch-all
@ExceptionHandler(Exception.class)is a safety net: any exception not handled by a more specific method lands here as a 500. This prevents stack traces from leaking to the client. - The error body always includes
status,error,message, andtimestamp: a consistent shape the front end can rely on.
3. Exception-to-status mapping
| Exception | HTTP Status | Why |
|---|---|---|
| ActiveSessionExistsException | 409 Conflict | Request valid but conflicts with current state |
| TableSessionNotFound | 404 Not Found | Requested resource doesn't exist |
| NoActiveTableSessionFoundException | 404 Not Found | No active session matches the lookup |
| NoActiveSessionsFoundExceptions | 404 Not Found | No active sessions exist at all |
| MenuItemIdNotFoundException | 404 Not Found | Menu item ID not in the database |
| MenuItemNotFoundException | 404 Not Found | Menu item lookup by criteria failed |
| MenuItemNotAvailableException | 400 Bad Request | Item exists but is not available to order |
| OrderNotFoundException | 404 Not Found | Order ID not found |
| ResourceNotFoundException | 404 Not Found | Generic resource not found |
| Exception (catch-all) | 500 Internal Server Error | Unexpected / unhandled error |
4. How it all connects
The flow from a service call to a JSON error response:
// Service throws a domain exception:
MenuItem menuItem = menuItemRepository.findById(id)
.orElseThrow(MenuItemIdNotFoundException::new); // thrown here
// GlobalExceptionHandler catches it:
@ExceptionHandler(MenuItemIdNotFoundException.class)
public ResponseEntity<Map<String, Object>> handleMenuItemIdNotFound(
MenuItemIdNotFoundException ex) {
// ... builds error map ...
return new ResponseEntity<>(error, HttpStatus.NOT_FOUND); // 404 returned to client
}
// Client receives:
// HTTP 404
// {
// "status": 404,
// "error": "Menu Item Not Found",
// "message": "Menu item with id 42 not found",
// "timestamp": "2025-05-10T14:32:00.000Z"
// }
- The service has zero HTTP knowledge: it just throws a domain exception. The handler does the HTTP translation.
orElseThrow(MenuItemIdNotFoundException::new): the constructor reference creates a new instance of the exception. Use a lambda() -> new MenuItemIdNotFoundException(id)when you need to pass arguments.Instant.now()gives a UTC timestamp in ISO-8601 format: no timezone ambiguity.
Key Takeaways
- One exception class per domain failure: the name makes the problem self-documenting without needing a comment.
@RestControllerAdvicecentralises all HTTP error handling in one place, keeping service classes free of HTTP concerns.- Always include a catch-all
@ExceptionHandler(Exception.class)to prevent stack traces from reaching the client. - Map exception types to HTTP status codes semantically: 404 for missing resources, 409 for conflicts, 400 for bad domain state, 500 for unexpected errors.
- Use
Instant.now()for timestamps in error responses: always UTC, always consistent.