How to Build a Clean Unified Exception Handling System in Spring Boot

This article walks through the problem of scattered try‑catch blocks in Java back‑ends, explains the use of Spring's @ControllerAdvice together with a custom Assert‑based validation and error‑code enum, and demonstrates a complete unified exception handling solution with production‑ready response formatting and extensive code examples.

Architect
Architect
Architect
How to Build a Clean Unified Exception Handling System in Spring Boot

Background

In Java backend development, handling exceptions with repetitive try { … } catch { … } finally { … } blocks reduces readability and creates a lot of boilerplate code. The article shows how to replace these scattered blocks with a unified exception handling mechanism that keeps business logic clean and concise.

What Is Unified Exception Handling

Spring 3.2 introduced @ControllerAdvice, which can be combined with @ExceptionHandler, @InitBinder, and @ModelAttribute to apply cross‑cutting concerns to all controllers. Only @ExceptionHandler directly deals with exception processing, allowing a single class to define handling logic for any controller.

Assert‑Based Validation

Instead of writing explicit

if (obj == null) { throw new IllegalArgumentException(...); }

checks, the author uses Spring's org.springframework.util.Assert and creates a custom Assert interface that throws domain‑specific BaseException. Test methods illustrate the readability gain:

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

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

The custom Assert interface defines default methods assertNotNull(Object obj) and assertNotNull(Object obj, Object... args) that delegate to newException methods, enabling the exception type and message to be derived from an enum.

/**
 * Create an exception instance.
 * @param args arguments for message formatting
 * @return a new BaseException
 */
BaseException newException(Object... args);

/**
 * Create an exception instance with a cause.
 * @param t   the throwable cause
 * @param args arguments for message formatting
 * @return a new BaseException
 */
BaseException newException(Throwable t, Object... args);

/**
 * Assert that the given object is not null; otherwise throw the exception defined by the enum.
 * @param obj the object to check
 */
default void assertNotNull(Object obj) {
    if (obj == null) {
        throw newException(obj);
    }
}

/**
 * Assert that the given object is not null; otherwise throw the exception with a formatted message.
 * @param obj  the object to check
 * @param args arguments for the message template
 */
default void assertNotNull(Object obj, Object... args) {
    if (obj == null) {
        throw newException(args);
    }
}

Enum for Error Codes

A response enum implements BusinessExceptionAssert and provides a pair of code and message. Adding a new error scenario only requires a new enum constant, not a new exception class.

/**
 * Business exception interface extending the response enum and custom Assert.
 */
public interface BusinessExceptionAssert extends IResponseEnum, Assert {
    @Override
    default BaseException newException(Object... args) {
        String msg = MessageFormat.format(this.getMessage(), args);
        return new BusinessException(this, args, msg);
    }

    @Override
    default BaseException newException(Throwable t, Object... args) {
        String msg = MessageFormat.format(this.getMessage(), args);
        return new BusinessException(this, args, msg, t);
    }
}

/**
 * Enum defining error codes and messages for business validation.
 */
@Getter
@AllArgsConstructor
public enum ResponseEnum implements BusinessExceptionAssert {
    BAD_LICENCE_TYPE(7001, "Bad licence type."),
    LICENCE_NOT_FOUND(7002, "Licence not found.");

    private final int code;
    private final String message;
}

Unified Exception Handler

The @ControllerAdvice class defines handlers for business exceptions, generic BaseException, servlet‑level exceptions, binding errors, validation errors, and unknown exceptions. It also distinguishes production and development environments to hide technical details from end users.

@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;

    /**
     * Retrieve an internationalized message for the given BaseException.
     */
    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;
    }

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

    /**
     * Handle generic BaseException (non‑business custom exceptions).
     */
    @ExceptionHandler(BaseException.class)
    @ResponseBody
    public ErrorResponse handleBaseException(BaseException e) {
        log.error(e.getMessage(), e);
        return new ErrorResponse(e.getResponseEnum().getCode(), getMessage(e));
    }

    /**
     * Handle servlet‑level exceptions such as 404, method not supported, etc.
     */
    @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 servletEnum = ServletResponseEnum.valueOf(e.getClass().getSimpleName());
            code = servletEnum.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());
    }

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

    /**
     * Handle method argument validation errors.
     */
    @ExceptionHandler(MethodArgumentNotValidException.class)
    @ResponseBody
    public ErrorResponse handleValidException(MethodArgumentNotValidException e) {
        log.error("Parameter validation error", e);
        return wrapperBindingResult(e.getBindingResult());
    }

    /**
     * Convert binding results into a unified error response.
     */
    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));
    }

    /**
     * Catch‑all handler for unexpected exceptions.
     */
    @ExceptionHandler(Exception.class)
    @ResponseBody
    public ErrorResponse handleException(Exception e) {
        log.error(e.getMessage(), e);
        if (ENV_PROD.equals(profile)) {
            int code = CommonResponseEnum.SERVER_ERROR.getCode();
            BaseException baseException = new BaseException(CommonResponseEnum.SERVER_ERROR);
            String message = getMessage(baseException);
            return new ErrorResponse(code, message);
        }
        return new ErrorResponse(CommonResponseEnum.SERVER_ERROR.getCode(), e.getMessage());
    }
}

Exception Classification

The handler groups exceptions into two major categories: controller‑level HTTP errors (e.g., 404, method not supported, media‑type mismatches) and service‑level business errors defined by the enum. The article lists each exception type and the corresponding response code.

Making 404 Throw an Exception

By adding the following properties, missing URLs trigger NoHandlerFoundException instead of the default Whitelabel page, allowing the unified handler to process them.

spring.mvc.throw-exception-if-no-handler-found=true
spring.resources.add-mappings=false

Unified Response Structure

All API responses share a base class containing code and message. Successful payloads use CommonResponse, and paginated results use QueryDataResponse. The author introduces shorthand wrappers R and QR for concise return statements, e.g., return new R<>(data); or return new QR<>(queryData);.

Verification

The article walks through a sample project that uses MyBatis‑Plus. It demonstrates how the handler captures:

Custom business errors such as Licence not found and Bad licence type.

Controller‑level errors like 404 (missing URL) and HTTP method not supported.

Parameter binding and validation errors, showing the aggregated error message.

Unexpected database errors when the entity class and table schema are mismatched.

Screenshots (omitted here) illustrate each scenario.

Conclusion

By combining Assert‑based validation, an error‑code enum, and a @ControllerAdvice handler, developers obtain a clean, maintainable way to manage most exceptions in a Spring backend. The solution scales across services and can be packaged as a reusable common module for future projects.

Exception classification diagram
Exception classification diagram
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.

BackendJavaException HandlingspringAssertUnified API
Architect
Written by

Architect

Professional architect sharing high‑quality architecture insights. Topics include high‑availability, high‑performance, high‑stability architectures, big data, machine learning, Java, system and distributed architecture, AI, and practical large‑scale architecture case studies. Open to ideas‑driven architects who enjoy sharing and learning.

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.