Mastering Unified Exception Handling in Spring: Clean Code, Better Readability

This article analyzes the problem of scattered try‑catch blocks in Java services, explains why per‑controller exception methods are cumbersome, and presents a unified exception handling solution using @ControllerAdvice, custom Assert utilities, enum‑based error codes, and environment‑aware response formatting, all illustrated with concrete code examples and test results.

Architect
Architect
Architect
Mastering Unified Exception Handling in Spring: Clean Code, Better Readability

Background

In Java projects a large portion of development time is spent handling exceptions, leading to repetitive try { ... } catch { ... } finally { ... } blocks that hurt readability. Two screenshots compare a messy controller full of try‑catch statements with a clean, elegant controller that delegates error handling.

What Is Unified Exception Handling?

Spring 3.2 introduced @ControllerAdvice which can be combined with @ExceptionHandler, @InitBinder, and @ModelAttribute. The @ExceptionHandler annotation marks a method that will be invoked when a specific exception is thrown from any controller, eliminating the need to duplicate error‑handling code in each controller class.

Why Not Use a BaseController?

Creating a BaseController class would still couple business logic to a specific inheritance hierarchy, which is problematic because Java only supports single inheritance. A more decoupled approach is needed.

Solution Overview

The solution combines three ideas:

Use @ControllerAdvice to apply a single exception handler to all controllers.

Replace explicit if checks with an Assert utility that throws custom exceptions.

Define error codes and messages in an enum so that adding a new business error only requires a new enum constant.

Assert Utility Example

public abstract class Assert {
    public static void notNull(@Nullable Object object, String message) {
        if (object == null) {
            throw new IllegalArgumentException(message);
        }
    }
}

The test shows the difference between using Assert.notNull(user, "User not found.") and a manual

if (user == null) { throw new IllegalArgumentException("User not found."); }

block.

Enum‑Based Error Definition

public interface IResponseEnum {
    int getCode();
    String getMessage();
}

public class BusinessException extends BaseException {
    public BusinessException(IResponseEnum responseEnum, Object[] args, String message) {
        super(responseEnum, args, message);
    }
}

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);
    }
}

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

Using ResponseEnum.LICENCE_NOT_FOUND.assertNotNull(licence) automatically throws a BusinessException with the appropriate code and message.

Unified Exception Handler

@Slf4j
@Component
@ControllerAdvice
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 servletEnum = ServletResponseEnum.valueOf(e.getClass().getSimpleName());
            code = servletEnum.getCode();
        } catch (IllegalArgumentException ignored) {
            log.error("class [{}] not defined in enum {}", e.getClass().getName(), ServletResponseEnum.class.getName());
        }
        if (ENV_PROD.equals(profile)) {
            BaseException be = new BaseException(CommonResponseEnum.SERVER_ERROR);
            return new ErrorResponse(code, getMessage(be));
        }
        return new ErrorResponse(code, e.getMessage());
    }

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

    @ExceptionHandler(MethodArgumentNotValidException.class)
    @ResponseBody
    public ErrorResponse handleValidException(MethodArgumentNotValidException e) {
        log.error("Parameter binding validation error", 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 be = new BaseException(CommonResponseEnum.SERVER_ERROR);
            return new ErrorResponse(CommonResponseEnum.SERVER_ERROR.getCode(), getMessage(be));
        }
        return new ErrorResponse(CommonResponseEnum.SERVER_ERROR.getCode(), e.getMessage());
    }
}

The handler categorises exceptions into three groups: pre‑controller (e.g., 404, method not allowed), custom business exceptions, and unknown exceptions. It also demonstrates how to hide detailed stack traces in production by returning a generic "Network error" message.

Unified Response Structure

All API responses share a base class containing code and message. Successful responses extend this with a data field ( CommonResponse), while paginated results use QueryDataResponse. For brevity the project defines shortcut classes R<T> and QR<T> that wrap the common structures.

Verification Steps

To verify the solution a sample Spring Boot project using MyBatis‑Plus is created. The LicenceService demonstrates:

Fetching a licence by ID and using ResponseEnum.LICENCE_NOT_FOUND.assertNotNull(licence).

Paginated queries with ResponseEnum.BAD_LICENCE_TYPE.assertNotNull(licenceTypeEnum).

Creating a licence inside a @Transactional method.

Test cases include:

Requesting a non‑existent licence ID (triggers LICENCE_NOT_FOUND).

Providing an invalid licence type (triggers BAD_LICENCE_TYPE).

Calling an undefined URL to provoke a 404 ( NoHandlerFoundException).

Using an unsupported HTTP method to provoke HttpRequestMethodNotSupportedException.

Submitting empty query parameters to trigger validation errors.

Submitting an invalid request body to trigger MethodArgumentNotValidException.

Adding a new field to the entity without updating the database to provoke a database error.

Each scenario returns a JSON payload containing code and message, confirming that the unified handler captures and formats errors correctly.

Extension for Production Environments

When spring.profiles.active=prod, the handler replaces detailed exception messages with a generic "Network error" to avoid exposing internal details to end users.

Conclusion

By combining @ControllerAdvice, an Assert utility, and enum‑based error definitions, developers can eliminate the majority of repetitive try‑catch blocks, achieve consistent error responses, and simplify the addition of new business errors without creating many custom exception classes.

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