Defining the standard exception handling and error response body to the spring base application

Toomtarm Kung
10 min readApr 22, 2021
Credit: https://unsplash.com/photos/XkKCui44iM0

I believe that many Java developers have been confronting with some troubles regarding exception management. How could I make it clean, manageable, and beautiful? If you are still having these troubles, I’ll fill your heart with gladness - take away all your sadness - ease your troubles, that’s what I’ll do 🎶 🎸 🎹

Before we start, it’s important to design a general error response body. This will be absolutely applied to the whole system. I have mine but you might have different yours. Nonetheless, the concept still be the same, only implementation would be changed, so don’t worry. 🤔🤔🤔

{
"code": 401,
"reason": "arbitrary text or the i18n key for the translation",
"errors": {
"field1": ["arbitrary text or the i18n key for the translation"],
"field2": ["arbitrary text or the i18n key for the translation"],
}
}

This is my design with a total of 3 properties.

code: Represents the response code. It is very convenient for the client application to manage only the response body rather than the HTTP response.
reason: The overall cause of failure. Why user receives this error response for instance “Invalid input”, or “No such require parameters
errors: This is optional data (the default is an empty object) used to indicate all specific problems. Since one request might involve numerous parts, especially the form submission or the data import. Many problems ought to be introduced at the same time so that users can fix them all at once. 🤓🤓🤓

For instance,

{
"code": 422,
"reason": "Invalid input",
"errors": {
"id": ["Accept only UUID"],
"name": ["Accept only Alphabet", "Length exceed 50 characters"]
}
}

Having only HTTP response code might not be enough, so some systems also introduce their own error code (on top of the HTTP response code).

If you used to integrate your system with some service providers, they sometimes have their magic error codes, like 522 or 388, that you need to look into their description table.

Some might have a better design like 6 digits where the first 3 refer to the HTTP response code, and the other for the specific error. For instance, 403005 might refers to “No such permission to request other user’s data” whereas 401003 might refers to “Invalid MFA authentication”. If you have any kind of design like this, you can introduce a new key in step 5.

There are totally 7 steps, where 5 of which are mandatory implementation, 1 optional implementation, and 1 testing step. It’s time to begin. 🎬🎬🎬

🎯 Step 1: Create a custom Logging annotation

Logging is one of the standard ways to track system activities and issues. Well-managed logging usually helps you rapidly reach problems but over logging not only slows down the whole system but also difficult to find the problem.

There are some situations I need to pay attention to especially the unexpected ones that should never happen. For instance, the duplication of the data that should have either 0 or 1 record (might because of verification issues or a previous bug I’ve just fixed). Then, raising a specific runtime exception with details might be a good approach since it stops the execution immediately. Logging at the severe level would be the place where all developers need to observe all the time, and leave any current tasks behind and focus on any severe issues that just takes place.

In order to achieve that goal, I decided to create a new Loggingannotation so that it could be applied to all of my custom exceptions. My intention is to create a simple logging control on top of the exceptions. Nevertheless, any user can ignore applying this to some exceptions. However, I strongly recommend to explicitly specify at least NONE level to all custom exceptions.

import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Logging {

Level level() default Level.NONE;

enum Level {
NONE, INFO, DEBUG, ERROR, WARN, TRACE
}
}

🎯 Step 2: Create an interface to handle multiple error messages

How could I ensure that an exception comes along with additional multiple error messages? You might have some other solutions. Personally, I believe that specifying a simple interface is the easiest approach for everybody not only me.

Therefore, I decided to create a simple new interface MultipleErrorMessage. Implement this interface will force all custom exceptions to provide the error messages inevitably. Then, I could now assume that only exceptions that implement this interface will come along with error messages, and ignore others.

import java.util.List;
import java.util.Map;
public interface MultipleErrorMessage {

Map<String, List<String>> getErrorMessages();

}

🎯 Step 3: Create a logging handler

From the 2 steps above, I have finished all metadata I need. Now, it’s time to implement the handler.

The responsibility of the logging handler is to log the exception message (which is a sensitive internal issue) follow the specified logging level (if enabled). This class will not involve with any error messages in the HTTP response because they are the ones I want to expose to clients. Therefore, I create a new ErrorMessageLoggingHandler and there are 2 cases I need to cover.

  1. Any custom exception with Logging annotation specified
  2. Any exception without any Logging annotation (might be our custom exception or from libs)
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.NoSuchBeanDefinitionException;

import java.util.HashMap;
import java.util.Map;

public final class ErrorMessageLoggingHandler {

private final Logger logger = LoggerFactory.getLogger(getClass());

private final Map<String, Logging.Level> classAndLogLevelMap = new HashMap<>() {{
put(NoSuchBeanDefinitionException.class.getName(), Logging.Level.ERROR);
}};

public void perform(Exception exception) {
Class<? extends Exception> exceptionClass = exception.getClass();
Logging.Level logLevel = (exceptionClass.isAnnotationPresent(Logging.class))
? exceptionClass.getAnnotation(Logging.class).level()
: classAndLogLevelMap.get(exceptionClass.getName());

if (logLevel == null) {
if (logger.isWarnEnabled()) {
logger.warn(exception.getClass().getName() + " is not yet specify logging level");
}
return;
}

if (logLevel == Logging.Level.NONE) {
return;
}

String logMessage = exception.getMessage();
switch (logLevel) {
case INFO:
if (logger.isInfoEnabled()) {
logger.info(logMessage, exception);
}
break;
case ERROR:
if (logger.isErrorEnabled()) {
logger.error(logMessage, exception);
}
break;
case DEBUG:
if (logger.isDebugEnabled()) {
logger.debug(logMessage, exception);
}
break;
case WARN:
if (logger.isWarnEnabled()) {
logger.warn(logMessage, exception);
}
break;
case TRACE:
if (logger.isTraceEnabled()) {
logger.trace(logMessage, exception);
}
break;
}
}
}

There is an interesting part of this class at private final Map<String, Logging.Level> classAndLogLevelMap. This is the mapping between an exception and its log level. Since I cannot apply the Logging annotation to any exceptions from libs and/or frameworks. Therefore, developers might occasionally need to update this class in case the new lib added or lib/framework version has been updated

You can improve this class by moving this mapping as an external file, then read it at the start time, and finally pass it through the constructor.

🎯 Step 4. Create an exception handler

This is similar to 3rd step. However, this handler will instead apply the multiple error messages from the custom interface instead of the exception message.

The responsibility of the exception handler is to create a response body, in the format that I have designed at the beginning, from an exception. I start by creating a new ErrorMessageExceptionHandler and there are 4 cases I need to cover.

  1. Any custom exception that extends theResponseStatusException
  2. Any custom exception that applies the @ResponseStatus annotation
  3. Any exceptions from libs and/or framework
  4. Any unexpected exception that I did not yet handle. This usually happens when I upgrade the version and/or add some new dependencies, then some new not yet known exceptions have been disclosed.

From the cases above, 1st will take precedence over 2nd if any exceptions apply both. Only 1st, and 2ndare legitimate for applying multiple error messages.

import com.fasterxml.jackson.databind.exc.InvalidFormatException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.NoSuchBeanDefinitionException;
import org.springframework.http.HttpStatus;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.web.HttpRequestMethodNotSupportedException;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.multipart.MaxUploadSizeExceededException;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.web.servlet.NoHandlerFoundException;

import java.nio.file.AccessDeniedException;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

public final class ErrorMessageExceptionHandler {

public final String KEY_CODE = "code";
public final String KEY_REASON = "reason";
public final String KEY_ERRORS = "errors";

private final Logger logger = LoggerFactory.getLogger(getClass());
private final Map<String, HttpStatus> exceptionResponseMap = new HashMap<>() {{
put(IllegalArgumentException.class.getName(), HttpStatus.BAD_REQUEST);
put(InvalidFormatException.class.getName(), HttpStatus.BAD_REQUEST);
put(NoHandlerFoundException.class.getName(), HttpStatus.NOT_FOUND);
put(AccessDeniedException.class.getName(), HttpStatus.FORBIDDEN);
put(HttpRequestMethodNotSupportedException.class.getName(), HttpStatus.METHOD_NOT_ALLOWED);
put(HttpMessageNotReadableException.class.getName(), HttpStatus.BAD_REQUEST);
put(MaxUploadSizeExceededException.class.getName(), HttpStatus.PAYLOAD_TOO_LARGE);
put(NoSuchBeanDefinitionException.class.getName(), HttpStatus.INTERNAL_SERVER_ERROR);
}};

private final Map<String, String> errorReasonMap = new HashMap<>() {{
put(IllegalArgumentException.class.getName(), "Incorrect parameter type");
put(InvalidFormatException.class.getName(), "Invalid format");
put(NoHandlerFoundException.class.getName(), "The resource might not exists");
put(AccessDeniedException.class.getName(), "You don't have permission");
put(HttpRequestMethodNotSupportedException.class.getName(), "Http method is not support");
put(HttpMessageNotReadableException.class.getName(), "Malformed JSON request");
put(MaxUploadSizeExceededException.class.getName(), "The upload file is too large");
put(NoSuchBeanDefinitionException.class.getName(), "There are some internal error");
}};

public Map<String, Object> createHttpResponseData(Exception ex) {
Map<String, List<String>> errorMessages = (ex instanceof MultipleErrorMessage)
? ((MultipleErrorMessage) ex).getErrorMessages()
: Collections.emptyMap();

if (ex instanceof ResponseStatusException) {
ResponseStatusException exception = (ResponseStatusException) ex;
return new HashMap<>() {{
put(KEY_CODE, exception.getStatus().value());
put(KEY_REASON, exception.getReason());
put(KEY_ERRORS, errorMessages);
}};
}

Class<? extends Exception> exception = ex.getClass();
if (exception.isAnnotationPresent(ResponseStatus.class)) {
ResponseStatus responseStatus = exception.getAnnotation(ResponseStatus.class);
return new HashMap<>() {{
put(KEY_CODE, responseStatus.code().value());
put(KEY_REASON, responseStatus.reason());
put(KEY_ERRORS, errorMessages);
}};
}

/*
* Map the HttpStatus for exception classes that cannot be modified (basically exceptions from any lib)
*/
HttpStatus status = exceptionResponseMap.get(ex.getClass().getName());
if (status != null) {
return new HashMap<>() {{
put(KEY_CODE, status.value());
put(KEY_REASON, errorReasonMap.get(ex.getClass().getName()));
put(KEY_ERRORS, Collections.emptyMap());
}};
}

/*
* Any unexpected exception that need to be fixed and identified the response code, message, etc
*/
logger.error("Unexpect exception", ex);
return new HashMap<>() {{
put(KEY_CODE, HttpStatus.INTERNAL_SERVER_ERROR.value());
put(KEY_REASON, "Internal Server Error");
put(KEY_ERRORS, Collections.emptyMap());
}};
}
}

This is similar to the ErrorMessageLoggingHandler. Hence, the solution could be similar.

🎯 Step 5: Create an implementation of ControllerAdvice to catch all exceptions of controllers

Spring provides a ControllerAdvice annotation to allow developers to control their exceptions. There are 2 types of projects I used to work with.

First, projects that never use any ControllerAdvice but instead controls the response error by returning a ResponseEntity at each method of the controllers. It means each method needs to take care of all possibilities, both success, and failure. Such overabundant code, tons of if-else statements, and steep learning curve are ubiquitous inevitably. 😱😱😱

The other is projects that mess up the exception at the ControllerAdvice. This kind of project is obviously easier to manage. Lots of exception handlers (for each exception class) can slow down the development at the end. Imagine that your current project has around 20 exceptions under ControllerAdvice, what would you do? Would you separate some of them into new ControllerAdvices? I think this will cause more trouble because the project could have more exceptions every day. Eventually, the whole team will end up with a never-ending problem. Also, scattering those to multiple places might cause inconsistent behavior if somebody adds a duplicate exception among ControllerAdvices. 🤔🤔🤔

Limit the number of exceptions and/or create a forbidden guideline about introducing a new exception might cause more trouble because the project will be full of unreasonable and/or unspecific exception handling since developers need to use the existing one that never fits their problem. Would it be easy if you need to investigate urgent issues from an IllegalStateException with a non-specific context error message and stack trace? What if developers forget to log the issue? What if developers throw the same exception twice but log errors at different levels? Would it be more convenient if they need to only focus on throwing the well-configure ready-to-use reasonable exception without worrying about mistakes and also reduce some dirty logging code? Does this consistent approach benefit the whole team?

My solution needs only one simple ControllerAdvice along with taking advantage of spring ControllerAdvice annotation to let spring know that this bean is used to manage the exception. Make sure that the component scan includes this class. 😏😏😏

import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import com.fasterxml.jackson.databind.ObjectMapper;
import javax.servlet.http.HttpServletResponse;
import java.util.Map;

@ControllerAdvice()
public class GlobalControllerExceptionHandler {
private final ObjectMapper objectMapper = new ObjectMapper();
private final ErrorMessageLoggingHandler loggingHandler = new ErrorMessageLoggingHandler();
private final ErrorMessageExceptionHandler exceptionHandler = new ErrorMessageExceptionHandler();

@ExceptionHandler(Exception.class)
public void handleGeneralException(Exception ex, HttpServletResponse response) {
loggingHandler.perform(ex);
Map<String, Object> responseData = exceptionHandler.createHttpResponseData(ex);
Integer responseCode = (Integer) responseData.get(exceptionHandler.KEY_CODE);

response.setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE);
response.setStatus(responseCode);
response.getWriter().write(objectMapper.writeValueAsString(responseData));
}

}

After finish, these 5 steps, all exceptions of all controllers will be used to create a beautiful error response body. Now, you can control all of the responses through those exceptions. If the 6th step is not what you need, go to the 7th. 🏄🏄🏄

🎯 Step 6: (Optional) Handle the exception at the filter layer

This optional step needs to be implemented in case of using the Filter because the ControllerAdvice will never know the exception at the filter step, so you need to create a global filter to catch the exception and handle it like this.

import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.web.filter.OncePerRequestFilter;

import javax.servlet.FilterChain;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.Map;

public final class GlobalFilterExceptionHandler extends OncePerRequestFilter {

private final ObjectMapper objectMapper = new ObjectMapper();
private final ErrorMessageLoggingHandler loggingHandler = new ErrorMessageLoggingHandler();
private final ErrorMessageExceptionHandler exceptionHandler = new ErrorMessageExceptionHandler();

@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws IOException {
try {
filterChain.doFilter(request, response);
}
catch (Exception ex) {
loggingHandler.perform(ex);
Map<String, Object> responseData = exceptionHandler.createHttpResponseData(ex);
Integer responseStatus = (Integer) responseData.get(exceptionHandler.KEY_CODE);

response.setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE);
response.setStatus(responseStatus);
response.getWriter().write(objectMapper.writeValueAsString(responseData));
}
}
}

Make sure the filter has been registered to servlet e.g web.xml (spring mvc)

<filter>
<filter-name>exceptionHandlerFilter</filter-name>
<filter-class>your.package.GlobalFilterExceptionHandler</filter-class>
</filter>

<filter-mapping>
<filter-name>exceptionHandlerFilter</filter-name>
<url-pattern>/*</url-pattern>
</filter-mapping>

🎯 Step 7: Create both a custom exception and a controller, then test it!

Now, It’s time to create a new custom exception, then throw it out in order to

  1. Verify if the response body format is what you desire or change the format at ErrorMessageExceptionHandler
  2. Verify if the logging works properly, the logic is at ErrorMessageLoggingHandler
@Logging(level = Logging.Level.ERROR)
@ResponseStatus(code = HttpStatus.UNPROCESSABLE_ENTITY, reason = "Missing require parameters")
class MyCustomException extends RuntimeException implements MultipleErrorMessage {

private final Map<String, List<String>> errorMessage;

MyCustomException(String message, Map<String, List<String>> errorMessage) {
super(message);
this.errorMessage = Map.copyOf(errorMessage);
}

@Override
public Map<String, List<String>> getErrorMessages() {
return errorMessage;
}
}
@RestController
public class MyCustomController {
@RequestMapping(path = "/exception", method = RequestMethod.GET)
public Object exception() throws Exception {
throw new MyCustomException("Intend to be error", Map.of("key", List.of("value1", "value2")));
}
}
HTTP/1.1 422 Unprocessable Entity
Content-Type: application/json;charset=utf-8
Transfer-Encoding: chunked
Server: Jetty(9.4.29.v20200521)
{
"reason": "Missing require parameters",
"code": 422,
"errors": {
"key": ["value1", "value2"]
}
}

🎯 Implementation Tips

There are some people told me that applying this approach will cause tons of exception in the systems. Choosing one among 100 is not easy, or maybe worse. Fortunately, there is a panacea to get rid of this situation, the package-private. Any package-private classes will be available only within that package. Therefore, creating an internal exception class to be specific to a particular problem is now protected within that package. I think it’s okay to have the same exception names among the project as long as those are package-private.

This approach would be much more sense when a DuplicateUserException might need to produce different types of responses. The com.abc.service.registration.DuplicateUserException might produces an HTTP 409 at a registration service if a citizen id has been applied. On the other hand, com.abc.service.authentication.DuplicateUserException produces an unexpected HTTP 500 at a login service. Logging the HTTP 409 could be omitted, but not the other that should be promptly investigated. Finally, the project would end up with a few public general exceptions, and abundant private specific exceptions.

--

--