WL
Software Engineer
Wassim Lagnaoui

Tutorial 10: Global Exception Handling

Custom exceptions, @RestControllerAdvice, and mapping exception types to the right HTTP status codes.

← Back to Tutorials

Without 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 RuntimeException means callers don't need a try/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 @ControllerAdvice and @ResponseBody. Every @ExceptionHandler return 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, and timestamp: a consistent shape the front end can rely on.

3. Exception-to-status mapping

ExceptionHTTP StatusWhy
ActiveSessionExistsException409 ConflictRequest valid but conflicts with current state
TableSessionNotFound404 Not FoundRequested resource doesn't exist
NoActiveTableSessionFoundException404 Not FoundNo active session matches the lookup
NoActiveSessionsFoundExceptions404 Not FoundNo active sessions exist at all
MenuItemIdNotFoundException404 Not FoundMenu item ID not in the database
MenuItemNotFoundException404 Not FoundMenu item lookup by criteria failed
MenuItemNotAvailableException400 Bad RequestItem exists but is not available to order
OrderNotFoundException404 Not FoundOrder ID not found
ResourceNotFoundException404 Not FoundGeneric resource not found
Exception (catch-all)500 Internal Server ErrorUnexpected / 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.
  • @RestControllerAdvice centralises 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.