· Jose Antonio López  · 8 min read

Global Exception Handling in Spring Boot with @RestControllerAdvice and @ExceptionHandler

Learn how to handle global exceptions in Spring Boot using @RestControllerAdvice and @ExceptionHandler. Improve your REST APIs with best practices.

Learn how to handle global exceptions in Spring Boot using @RestControllerAdvice and @ExceptionHandler. Improve your REST APIs with best practices.

Objective

A real guide on how to configure global exception handling in Spring Boot 3.5.3:

FeatureDescription
Global Exception HandlingUse @RestControllerAdvice and @ExceptionHandler to handle exceptions centrally
A Single Domain ExceptionAvoid having domain exceptions everywhere (see example with SafeBoxException)
Convert to API ResponseTransform the internal exception into something the consumer understands

| Error codes | Centralize errors using enums |

Prerequisites

To follow this article, you need to have installed:

Tool/StepDownload link
JavaDownload Java
MavenDownload Maven
Spring Boot 3.5.3 projectCreate Spring Boot project

Dependencies

The dependencies used in this article are:

DependencyDescription
spring-boot-starter-webRequired dependencies to build web applications with servlet. More in the official documentation.
lombokLibrary that reduces boilerplate code using annotations. More in the official documentation.

I chose Maven as the dependency manager because I feel more comfortable with it. If you prefer Gradle, you can use it without any issues.

Design Decisions

CRM

The code in this article is based on a fictional project called CRM (Customer Relationship Management). The code you will see is a simplified version of a real project I am developing. The goal is to show how to handle exceptions globally in Spring Boot.

Onion Architecture

The architecture will follow the onion architecture pattern. It allows you to separate responsibilities into three main layers:

LayerDescription
DomainEntities, service interfaces, and domain exceptions.
ApplicationApplication service implementations, mappers, and DTOs.
InfrastructureControllers, filters, application configuration, JPA repository interface, and repository implementation.

You can see the structure below:

crm/
├── CrmApplication.java
├── domain
│   ├── exceptions
│   │   ├── CrmErrorMessage.java
│   │   └── CrmException.java
│   └── services
│       └── ItemService.java
├── application
│   └── services
│       └── ItemServiceDefaultImpl.java
└── infrastructure
  ├── dtos
  │   └── ApiCrmError.java
  └── handlers
    └── GlobalExceptionHandler.java

Lombok

To reduce boilerplate code, I use Lombok, which hides repetitive code using annotations. In this case, only the @Getter annotation is used.

Domain Exception

The domain exception is thrown when a business logic error occurs in the application. In this case, an exception called CrmException is defined.

CrmException.java
package com.test.crm.domain.exceptions;
@Getter
public class CrmException extends RuntimeException {
    private final CrmErrorMessage errorMessage;

    public CrmException(CrmErrorMessage errorMessage) {
        super();
        this.errorMessage = errorMessage;
    }

}

Code Explanation

ElementDescription
@GetterLombok annotation that generates the getErrorMessage() method to access the error message.
CrmExceptionClass extending RuntimeException representing a domain exception in the application.
CrmErrorMessageEnum containing the application’s error messages. Defined below.

Using the super() operator is optional and considered redundant. I usually include it to make it clear that the parent class constructor is being called, which helps less experienced developers understand the code.

Extending RuntimeException makes the exception unchecked:

  • No need to declare the exception with throws in the method signature.
  • No need to handle the exception in a try-catch block.
  • Its stack trace is initialized automatically.

Error Codes

Error codes are a way to identify errors that can occur in the application. One of the best strategies I’ve seen is to use an enum to define error codes. In this case, an enum called CrmErrorMessage is defined.

CrmErrorMessage.java

package com.test.crm.domain.exceptions;

public enum CrmErrorMessage {
    //USER
    USER_NOT_FOUND(HttpStatus.NOT_FOUND, "User not found"),
    USER_ALREADY_EXISTS(HttpStatus.CONFLICT, "User already exists"),
    //CUSTOMER
    CUSTOMER_NOT_FOUND(HttpStatus.NOT_FOUND, "Customer not found"),
    CUSTOMER_ALREADY_EXISTS(HttpStatus.CONFLICT, "Customer already exists"),
    //FILES
    CREATE_DIRECTORY_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "Failed to create a directory"),
    UPLOAD_IMAGE_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "Failed to upload image"),
    DELETE_IMAGE_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "Failed to delete image"),
    IMAGE_NOT_FOUND(HttpStatus.NOT_FOUND, "Image not found"),

    //GENERIC
    INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "Something wrong happened"),
    BAD_REQUEST(HttpStatus.BAD_REQUEST, "Bad request"),
    READ_IMAGE_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "Failed to read image");

    private final HttpStatus httpStatus;
    private final String description;

    private CrmErrorMessage(HttpStatus httpStatus, String description) {
        this.httpStatus = httpStatus;
        this.description = description;
    }


    public static HttpStatus getHttpStatus(CrmErrorMessage errorMessage) {
        return Arrays.stream(CrmErrorMessage.values())
            .filter(element -> element.equals(errorMessage))
            .findFirst()
            .map(element -> element.getHttpStatus())
            .orElse(null);

    }

    public static String getMessage(CrmErrorMessage errorMessage) {
        return Arrays.stream(CrmErrorMessage.values())
            .filter(element -> element.equals(errorMessage))
            .findFirst()
            .map(element -> element.getDescription())
            .orElse(null);
    }

    public static CrmErrorMessage getFromHttpStatus(HttpStatus code) {
        return Arrays.stream(CrmErrorMessage.values())
            .filter(element -> element.getHttpStatus().equals(code))
            .findFirst()
            .orElse(INTERNAL_SERVER_ERROR);
    }
}

Code Explanation

Enum Fields

FieldDescription
httpStatusHTTP status code to return to the client. Uses org.springframework.http.HttpStatus
descriptionHuman-readable error description, intended for the API consumer to understand the problem.

@Getter

The @Getter annotation from Lombok generates the getHttpStatus() and getDescription() methods to access the httpStatus and description fields, respectively. These fields should only be accessible from within the class. That’s why @Getter is passed the AccessLevel.PRIVATE parameter.

Static Methods

MethodDescription
getHttpStatusReturns the HTTP status code associated with a specific error message from the enum.
getMessageGets the human-readable description for a specific error message from the enum.
getFromHttpStatusLooks up and returns the error message corresponding to a given HTTP status code.

The static methods allow you to get the HTTP status code and error description. The advantage is that the developer can retrieve information without needing to use a switch or multiple if statements.

Tips for Error Codes

This implementation is very useful for centralizing errors. The problem is that if you add too many errors, the enum grows a lot and becomes hard to maintain. Some tips to handle this:

TipDescription
Group errorsGroup errors by functionality. This will help you find errors faster and split the class if the project grows a lot.
Add comments per functionAdd comments in the enum to separate each functionality.
Descriptive namesUse clear and representative names for each error to help other developers.
Review periodicallyRegularly review the enum to remove obsolete or redundant errors, keeping the code clean and up to date.

Error DTO

The error DTO is a class that represents the body of the error response to be returned to the client. In this case, a class called ApiCrmError is defined.

ApiCrmError.java
package com.test.crm.infrastructure.dtos;
public record ApiCrmError(CrmErrorMessage code, String message) {
}

Code Explanation

The fields defined in the DTO are:

FieldDescription
codeError code of type CrmErrorMessage that identifies the type of error that occurred.
descriptionHuman-readable message describing the error so the API consumer can easily understand it.

Using a record reduces boilerplate code and the fields are immutable by default, which is a good practice for DTOs.

You can make it implement Serializable. It’s optional in this case. In my experience, sometimes I’ve run into issues for not including it in other contexts, such as serializing objects in cache.

Global Exception Handler

The global exception handler is responsible for capturing and handling exceptions thrown in the application. The goal is to centralize exception handling to avoid dealing with exceptions in every controller.

A class called GlobalExceptionHandler is defined:

GlobalExceptionHandler.java
package com.test.crm.infrastructure.handlers;
@RestControllerAdvice
public class GlobalExceptionHandler {
    @ExceptionHandler(CrmException.class)
    public ResponseEntity<ApiCrmError> handleCrmException(CrmException exception) {
        final CrmErrorMessage errorMessage = exception.getErrorMessage();
        final HttpStatus status = CrmErrorMessage.getHttpStatus(errorMessage);
        final ApiCrmError body = createApiCrmError(errorMessage);
        return ResponseEntity.status(status).body(body);
    }

    @ExceptionHandler(value = {
        MethodArgumentTypeMismatchException.class,
    })

    public ResponseEntity<ApiCrmError> handleExceptions(MethodArgumentTypeMismatchException exception) {
        final CrmErrorMessage errorMessage = CrmErrorMessage.BAD_REQUEST;
        final HttpStatus status = CrmErrorMessage.getHttpStatus(errorMessage);

        final String paramName = exception.getName();

        String requiredTypeName = "unknown";
        if (exception.getRequiredType() != null) {
            requiredTypeName = exception.getRequiredType().getSimpleName();
        }

        String providedTypeName = "unknown";
        if (exception.getValue() != null) {
            providedTypeName = exception.getValue().getClass().getSimpleName();
        }

        final String message = String.format(
            "Invalid value for parameter '%s'. Expected type: '%s', but got: '%s'.",
            paramName, requiredTypeName, providedTypeName
        );

        final ApiCrmError body = createApiCrmError(errorMessage, message);
        return ResponseEntity.status(status).body(body);
    }

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ApiCrmError> handleValidationExceptions(MethodArgumentNotValidException exception) {
        final Map<String, String> errors = new HashMap<>();

        exception.getBindingResult()
            .getFieldErrors()
            .forEach((error) -> {
                final String fieldName = error.getField();
                final String errorMessage = error.getDefaultMessage();
                errors.put(fieldName, errorMessage);
            });

        final CrmErrorMessage errorMessage = CrmErrorMessage.BAD_REQUEST;
        final HttpStatus status = CrmErrorMessage.getHttpStatus(errorMessage);
        final ApiCrmError body = new ApiCrmError(errorMessage, errors.toString());
        return new ResponseEntity<>(body, status);
    }

    @ExceptionHandler(Exception.class)
    public ResponseEntity<ApiCrmError> handleGenericException(Exception exception) {
        final CrmErrorMessage errorMessage = CrmErrorMessage.INTERNAL_SERVER_ERROR;
        final HttpStatus status = CrmErrorMessage.getHttpStatus(errorMessage);
        final ApiCrmError body = createApiCrmError(errorMessage);
        return ResponseEntity.status(status).body(body);
    }

    private ApiCrmError createApiCrmError(CrmErrorMessage errorMessage) {
        final String message = CrmErrorMessage.getMessage(errorMessage);
        return new ApiCrmError(errorMessage, message);
    }

    private ApiCrmError createApiCrmError(CrmErrorMessage errorMessage, String message) {
        return new ApiCrmError(errorMessage, message);
    }

}

Code Explanation

Annotations

AnnotationDescriptionDocumentation
@RestControllerAdviceIndicates that the class is a global exception handler. Allows capturing and handling exceptions thrown by any REST controller.@RestControllerAdvice
@ExceptionHandlerThe method will handle one or more specific exceptions. Allows you to define logic to process and respond to those exceptions.@ExceptionHandler

The most interesting parameters of @RestControllerAdvice are:

ParameterDescription
basePackagesAllows you to specify one or more base packages where the global exception handler will be applied. Only controllers in these packages will be affected. Example: @RestControllerAdvice(basePackages = "com.test.crm.infrastructure.controllers")
assignableTypesAllows you to specify concrete controller classes to which the advice will be applied. Example: @RestControllerAdvice(assignableTypes = {UserController.class, CustomerController.class})

These parameters let you limit the scope of global exception handling to certain packages or specific controllers, instead of applying it to the entire application. This is a preliminary step for migrating from a monolithic application to a more modular architecture and later to microservices.

handleCrmException

The handleCrmException method handles exceptions of type CrmException. It creates a response with the HTTP status code and the response body. The key is to have a single domain exception thrown throughout the application and pass it the error code and error message.

handleValidationExceptions

The handleValidationExceptions method handles Spring validation exceptions. It’s a good example of the many things you can do with exception handling.

handleGenericException

This method handles any exception not caught by the previous methods. It’s good practice to have a generic handler to catch unexpected errors and return a generic error message to the client.

@ControllerAdvice vs @RestControllerAdvice

Aspect@ControllerAdvice@RestControllerAdvice
PurposeDesigned for MVC controllers that return views (HTML, JSP, Thymeleaf).Intended for REST controllers that return data (JSON, XML).
Internal annotationUses @Component.Combines @ControllerAdvice and @ResponseBody to automatically serialize the response.
Typical useWeb applications with HTML pages and MVC templates.RESTful APIs that return structured data.
Return JSON/XMLRequires adding @ResponseBodyAutomatically returns serialized data

Example Usage in a Service

To throw a domain exception in a service, you can do the following:

ItemServiceDefaultImpl.java
@Service
public class ItemServiceDefaultImpl implements ItemService {
    private final ItemRepository itemRepository;
    private static final Logger logger = LogManager.getLogger(ItemServiceDefaultImpl.class);

    public ItemServiceDefaultImpl(ItemRepository itemRepository) {
        this.itemRepository = itemRepository;
    }

    @Override
    public ItemDTO getItem(Long itemId) {
        Optional<Item> persistedItem = itemRepository.findById(itemId);
        return persistedItem.map(ItemMapper::fromItem)
            .orElseThrow(() -> {
                logger.error("[ITEM] : Item with {} id not found", itemId);
                return new CrmException(CrmErrorMessage.ITEM_NOT_FOUND);
            });
    }
}
  • Spring Boot
  • Exception Handling
Share: