Building a custom exception handler in Spring Boot

A well-designed API should return predictable error responses when something goes wrong. Without centralised handling, controllers often contain repeated try-catch blocks, inconsistent status codes, and messages that expose implementation details.

Spring Boot provides a clean solution through @ControllerAdvice and @RestControllerAdvice. These annotations allow one component to handle exceptions from many controllers, keeping business logic focused on successful requests.

This pattern is useful for Java applications serving Australian customers, whether the system manages bookings in Sydney, online orders in Melbourne, or mobile users across several AEST and AWST time zones. Consistent errors also make frontend and integration work easier.

The examples use modern Spring Boot conventions, including ResponseEntity, validation exceptions, structured error bodies, and logging practices suitable for production systems.

Why centralised exception handling matters

An exception handler defines how application failures are translated into HTTP responses. A missing database record might become 404 Not Found, invalid input may produce 400 Bad Request, and an unexpected failure should normally result in 500 Internal Server Error.

Handling these cases in one place prevents different controllers from returning incompatible JSON structures. It also creates a stable contract for React, mobile, or third-party clients consuming the API.

A central handler should avoid returning stack traces or SQL messages to users. This is particularly important when an Australian business collects customer details under the Privacy Act 1988, since error payloads should not accidentally reveal personal or operational data.

Creating a domain exception

Start with a specific runtime exception for a common business failure. A product, booking, or account service can throw this exception when the requested record does not exist.

public class ResourceNotFoundException extends RuntimeException {

    public ResourceNotFoundException(String message) {
        super(message);
    }
}

The service layer can now express its intent clearly:

return repository.findById(id)
        .orElseThrow(() ->
                new ResourceNotFoundException("Customer was not found"));

The exception contains a safe message rather than database details. In a real application, it can also carry a resource type or identifier for structured logging, while the public response exposes only the information clients need.

Defining a global advice class

Use @RestControllerAdvice when the application primarily exposes REST endpoints. It combines controller advice with response-body handling, so returned objects are automatically serialised as JSON.

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(ResourceNotFoundException.class)
    public ResponseEntity<ApiError> handleNotFound(
            ResourceNotFoundException exception,
            HttpServletRequest request) {

        ApiError error = new ApiError(
                Instant.now(),
                404,
                "Not Found",
                exception.getMessage(),
                request.getRequestURI());

        return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error);
    }
}

For applications serving both browser pages and APIs, plain @ControllerAdvice may be more appropriate because individual handlers can return views or response entities as required.

Designing a consistent error response

A dedicated DTO gives every failure a predictable shape. The following record is concise and works well with Jackson in Spring Boot applications.

public record ApiError(
        Instant timestamp,
        int status,
        String error,
        String message,
        String path) {
}

Clients can use the HTTP status for broad behaviour and the fields for display, logging, or analytics. Avoid placing internal exception class names in the message field. A correlation ID is also useful when support staff need to trace an incident across application logs.

For production systems, Spring Framework 6 supports ProblemDetail, an implementation aligned with RFC 9457. It can reduce custom code while still allowing properties such as an error code or trace identifier.

Handling validation failures

Bean Validation errors are common when a request body is incomplete or contains an invalid value. Handle MethodArgumentNotValidException and return field-level messages that a frontend can associate with form controls.

@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<Map<String, Object>> handleValidation(
        MethodArgumentNotValidException exception) {

    Map<String, String> fields = exception.getBindingResult()
            .getFieldErrors()
            .stream()
            .collect(Collectors.toMap(
                    FieldError::getField,
                    FieldError::getDefaultMessage,
                    (first, second) -> first));

    Map<String, Object> body = Map.of(
            "status", 400,
            "error", "Validation failed",
            "fields", fields);

    return ResponseEntity.badRequest().body(body);
}

Use clear validation messages and avoid echoing sensitive submitted values. This matters for registration, payment, and identity forms, where Australian users expect careful handling of personal information.

Handling external service failures

Payment gateways, email providers, identity platforms, and market data APIs can fail independently of your application. Map known client or integration exceptions to useful statuses, while keeping provider-specific details in logs.

For example, an unavailable payment provider may justify 503 Service Unavailable, whereas a rejected card should usually produce a controlled business response rather than a generic server error. The same principle applies to a demo service that consumes blackjack strategy guidance: malformed upstream data should be handled as an integration failure, not exposed as a raw parsing exception.

When integrating an Australian payment provider, also verify that retry behaviour cannot create duplicate charges. Store an idempotency key and log the provider reference without recording full card details.

Catching unexpected exceptions safely

A final fallback prevents unhandled exceptions from producing inconsistent responses:

@ExceptionHandler(Exception.class)
public ResponseEntity<ApiError> handleUnexpected(
        Exception exception,
        HttpServletRequest request) {

    log.error("Unexpected failure at {}", request.getRequestURI(), exception);

    ApiError error = new ApiError(
            Instant.now(),
            500,
            "Internal Server Error",
            "An unexpected error occurred",
            request.getRequestURI());

    return ResponseEntity.internalServerError().body(error);
}

Never return exception.getMessage() from this handler. Log the complete exception on the server, but send a neutral message to the client. Configure log retention and access controls carefully, especially for systems operating under the Australian Privacy Act.

Test each handler with MockMvc or integration tests. Verify status codes, JSON fields, validation output, and the absence of stack traces so releases remain reliable during busy local trading periods such as end-of-financial-year sales.

Improving the handler for production

Use stable application error codes such as CUSTOMER_NOT_FOUND or PAYMENT_UNAVAILABLE. Codes are easier for clients to consume than human-readable text, which may change during maintenance or localisation.

Keep the advice class focused on translation between exceptions and HTTP responses. Business decisions belong in services, persistence failures should be translated deliberately, and authentication or authorisation errors should follow the security configuration used by the application.

A practical setup also includes structured logs, request correlation, monitoring for spikes in 4xx and 5xx responses, and tests for malformed JSON. With these safeguards, a custom exception handler becomes a dependable boundary between Spring Boot internals and the people or systems using the API.