· 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.

Objective
A real guide on how to configure global exception handling in Spring Boot 3.5.3:
| Error codes | Centralize errors using enums |
Prerequisites
To follow this article, you need to have installed:
Dependencies
The dependencies used in this article are:
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:
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.javaLombok
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.
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
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
throwsin the method signature. - No need to handle the exception in a
try-catchblock. - 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.
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
@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
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:
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.
package com.test.crm.infrastructure.dtos;
public record ApiCrmError(CrmErrorMessage code, String message) {
}Code Explanation
The fields defined in the DTO are:
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:
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
The most interesting parameters of @RestControllerAdvice are:
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
Example Usage in a Service
To throw a domain exception in a service, you can do the following:
@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