Master Unified Exception Handling in Spring Boot for Cleaner, More Maintainable Code

This article explains how to replace repetitive try‑catch blocks with Spring's @ControllerAdvice and Assert‑based assertions, introduces a unified error response structure, demonstrates handling of various servlet and business exceptions, and shows practical code examples for building robust backend services.

Su San Talks Tech
Su San Talks Tech
Su San Talks Tech
Master Unified Exception Handling in Spring Boot for Cleaner, More Maintainable Code

What Is Unified Exception Handling

Spring 3.2 introduced the @ControllerAdvice annotation, which can be used together with @ExceptionHandler, @InitBinder, and @ModelAttribute to apply exception handling logic across all controllers.

The only annotation directly related to exception handling is @ExceptionHandler, which marks a method as an exception processor for a specific controller class.

Goal

Eliminate more than 95% of try catch blocks by using Assert statements to validate business conditions, allowing developers to focus on business logic instead of repetitive error handling code.

Unified Exception Handling in Practice

Before defining a unified exception handler, we need a clean way to assert conditions and throw custom exceptions.

Replace throw with Assert

Spring provides org.springframework.util.Assert, which can be used in tests to make code more readable. Example:

@Test
public void test1() {
    ...
    User user = userDao.selectById(userId);
    Assert.notNull(user, "User does not exist.");
    ...
}

@Test
public void test2() {
    User user = userDao.selectById(userId);
    if (user == null) {
        throw new IllegalArgumentException("User does not exist.");
    }
}

We can create our own Assert interface that generates custom exceptions via an Enum:

public interface Assert {
    BaseException newException(Object... args);
    BaseException newException(Throwable t, Object... args);
    default void assertNotNull(Object obj) {
        if (obj == null) {
            throw newException(obj);
        }
    }
    default void assertNotNull(Object obj, Object... args) {
        if (obj == null) {
            throw newException(args);
        }
    }
}

By defining an enum that implements both a response code interface and the Assert interface, we can map each business error to a specific code and message without creating many exception classes.

public enum ResponseEnum implements BusinessExceptionAssert {
    BAD_LICENCE_TYPE(7001, "Bad licence type."),
    LICENCE_NOT_FOUND(7002, "Licence not found.");
    private int code;
    private String message;
    // methods omitted for brevity
}

Usage example:

private void checkNotNull(Licence licence) {
    ResponseEnum.LICENCE_NOT_FOUND.assertNotNull(licence);
}

Define the Unified Exception Handler Class

@Slf4j
@Component
@ControllerAdvice
@ConditionalOnWebApplication
@ConditionalOnMissingBean(UnifiedExceptionHandler.class)
public class UnifiedExceptionHandler {
    private static final String ENV_PROD = "prod";
    @Autowired
    private UnifiedMessageSource unifiedMessageSource;
    @Value("${spring.profiles.active}")
    private String profile;

    public String getMessage(BaseException e) {
        String code = "response." + e.getResponseEnum().toString();
        String message = unifiedMessageSource.getMessage(code, e.getArgs());
        if (message == null || message.isEmpty()) {
            return e.getMessage();
        }
        return message;
    }

    @ExceptionHandler(BusinessException.class)
    @ResponseBody
    public ErrorResponse handleBusinessException(BaseException e) {
        log.error(e.getMessage(), e);
        return new ErrorResponse(e.getResponseEnum().getCode(), getMessage(e));
    }

    @ExceptionHandler(BaseException.class)
    @ResponseBody
    public ErrorResponse handleBaseException(BaseException e) {
        log.error(e.getMessage(), e);
        return new ErrorResponse(e.getResponseEnum().getCode(), getMessage(e));
    }

    @ExceptionHandler({
        NoHandlerFoundException.class,
        HttpRequestMethodNotSupportedException.class,
        HttpMediaTypeNotSupportedException.class,
        MissingPathVariableException.class,
        MissingServletRequestParameterException.class,
        TypeMismatchException.class,
        HttpMessageNotReadableException.class,
        HttpMessageNotWritableException.class,
        HttpMediaTypeNotAcceptableException.class,
        ServletRequestBindingException.class,
        ConversionNotSupportedException.class,
        MissingServletRequestPartException.class,
        AsyncRequestTimeoutException.class
    })
    @ResponseBody
    public ErrorResponse handleServletException(Exception e) {
        log.error(e.getMessage(), e);
        int code = CommonResponseEnum.SERVER_ERROR.getCode();
        try {
            ServletResponseEnum servletExceptionEnum = ServletResponseEnum.valueOf(e.getClass().getSimpleName());
            code = servletExceptionEnum.getCode();
        } catch (IllegalArgumentException ex) {
            log.error("class [{}] not defined in enum {}", e.getClass().getName(), ServletResponseEnum.class.getName());
        }
        if (ENV_PROD.equals(profile)) {
            BaseException baseException = new BaseException(CommonResponseEnum.SERVER_ERROR);
            String message = getMessage(baseException);
            return new ErrorResponse(code, message);
        }
        return new ErrorResponse(code, e.getMessage());
    }

    @ExceptionHandler(BindException.class)
    @ResponseBody
    public ErrorResponse handleBindException(BindException e) {
        log.error("Parameter binding validation exception", e);
        return wrapperBindingResult(e.getBindingResult());
    }

    @ExceptionHandler(MethodArgumentNotValidException.class)
    @ResponseBody
    public ErrorResponse handleValidException(MethodArgumentNotValidException e) {
        log.error("Parameter binding validation exception", e);
        return wrapperBindingResult(e.getBindingResult());
    }

    private ErrorResponse wrapperBindingResult(BindingResult bindingResult) {
        StringBuilder msg = new StringBuilder();
        for (ObjectError error : bindingResult.getAllErrors()) {
            msg.append(", ");
            if (error instanceof FieldError) {
                msg.append(((FieldError) error).getField()).append(": ");
            }
            msg.append(error.getDefaultMessage() == null ? "" : error.getDefaultMessage());
        }
        return new ErrorResponse(ArgumentResponseEnum.VALID_ERROR.getCode(), msg.substring(2));
    }

    @ExceptionHandler(Exception.class)
    @ResponseBody
    public ErrorResponse handleException(Exception e) {
        log.error(e.getMessage(), e);
        if (ENV_PROD.equals(profile)) {
            BaseException baseException = new BaseException(CommonResponseEnum.SERVER_ERROR);
            String message = getMessage(baseException);
            return new ErrorResponse(CommonResponseEnum.SERVER_ERROR.getCode(), message);
        }
        return new ErrorResponse(CommonResponseEnum.SERVER_ERROR.getCode(), e.getMessage());
    }
}

The handler categorises exceptions into two main groups: servlet‑layer exceptions (e.g., 404, method not allowed, media type errors) and service‑layer exceptions (business, custom, or unknown). Each group is processed to return a consistent code and message JSON structure.

Unified Return Result

Define a base response class containing code and message. Extend it with BaseResponse, CommonResponse (adds data), ErrorResponse, and QueryDataResponse for paginated results. For brevity, wrapper classes R and QR can be used:

new R<>(data);
new QR<>(queryData);

Verification Steps

1. Access a non‑existent licence ID – the handler returns a Licence not found error.

2. Request a licence list with an invalid licence type – the handler returns a Bad licence type error.

3. Call an undefined endpoint – a 404 exception is captured and returned with a unified error code.

4. Use an unsupported HTTP method – the handler captures Request method not supported.

5. Trigger validation errors (empty parameters) – the handler aggregates field errors into a single message.

6. Simulate a database schema mismatch – the handler catches the database exception and returns a server‑error response.

Summary

By combining Assert assertions, an enum‑based error code system, and a global @ControllerAdvice handler, most exceptions in a Spring Boot backend can be captured and presented uniformly, improving code readability, reducing boilerplate, and providing consistent API responses.

Different stages of exception
Different stages of exception
Default Whitelabel Error Page
Default Whitelabel Error Page
Spring default /error controller
Spring default /error controller
Production environment returns generic network error
Production environment returns generic network error
Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

Su San Talks Tech
Written by

Su San Talks Tech

Su San, former staff at several leading tech companies, is a top creator on Juejin and a premium creator on CSDN, and runs the free coding practice site www.susan.net.cn.

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.