Master Unified Exception Handling in Spring with @ControllerAdvice and Assert

This article explains how to replace repetitive try‑catch blocks in Java Spring applications with a clean, centralized exception handling strategy using @ControllerAdvice, custom Assert utilities, and enum‑based error codes, while also showing how to return consistent error responses across services.

Programmer DD
Programmer DD
Programmer DD
Master Unified Exception Handling in Spring with @ControllerAdvice and Assert

Background

In software development, handling exceptions consumes a large portion of time, leading to many repetitive try { ... } catch { ... } finally { ... } blocks that hurt readability. The article compares two coding styles and argues for a cleaner approach.

Ugly try‑catch blocks

These blocks appear in both Controller and Service layers, making the code hard to read.

What is Unified Exception Handling?

Spring 3.2 introduced @ControllerAdvice which can be combined with @ExceptionHandler, @InitBinder, and @ModelAttribute. The key annotation for exception handling is @ExceptionHandler, which allows a method in a class annotated with @ControllerAdvice to handle specific exceptions thrown from any controller.

Without a unified approach, each controller would need its own exception handling method, leading to duplicated code and tight coupling.

Goal

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

Practical Unified Exception Handling

Replace throw exception with Assert

Spring provides org.springframework.util.Assert. Example:

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

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

The Assert class essentially wraps the if check and throws an exception when the condition fails.

Custom Assert Interface

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

Implementations can create domain‑specific exceptions instead of generic ones.

Enum‑Based Error Codes

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 BusinessException(IResponseEnum responseEnum, Object[] args, String message, Throwable cause) {
        super(responseEnum, args, message, cause);
    }
}

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

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

Each enum instance carries an error code and message, allowing concise validation like ResponseEnum.LICENCE_NOT_FOUND.assertNotNull(licence);.

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());
        return (message == null || message.isEmpty()) ? e.getMessage() : 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 ex) {
            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 exception", e);
        return wrapperBindingResult(e.getBindingResult());
    }

    @ExceptionHandler(MethodArgumentNotValidException.class)
    @ResponseBody
    public ErrorResponse handleValidException(MethodArgumentNotValidException e) {
        log.error("Parameter 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 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 groups exceptions into three categories: pre‑controller (e.g., 404, method not allowed), custom business exceptions, and unknown exceptions. In production, detailed messages are hidden to avoid exposing internal details.

404 Handling

By default Spring forwards 404 to /error. To make it throw an exception, set:

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

Then NoHandlerFoundException can be captured by the unified handler.

Unified Response Structure

All API responses share code and message. Successful responses also contain data. Base classes:

public class BaseResponse { private int code; private String message; }
public class CommonResponse<T> extends BaseResponse { private T data; }
public class ErrorResponse extends BaseResponse { }
public class QueryDataResponse<T> extends CommonResponse<QueryData<T>> { }

Convenient wrappers R<T> and QR<T> simplify usage.

Verification Demo

A sample project using MyBatis‑Plus demonstrates the flow:

@Service
public class LicenceService extends ServiceImpl<LicenceMapper, Licence> {
    @Autowired
    private OrganizationClient organizationClient;

    public LicenceDTO queryDetail(Long licenceId) {
        Licence licence = this.getById( licenceId );
        ResponseEnum.LICENCE_NOT_FOUND.assertNotNull( licence );
        OrganizationDTO org = ClientUtil.execute(() -> organizationClient.getOrganization(licence.getOrganizationId()));
        return toLicenceDTO(licence, org);
    }

    public QueryData<SimpleLicenceDTO> getLicences(LicenceParam licenceParam) {
        LicenceTypeEnum type = LicenceTypeEnum.parseOfNullable(licenceParam.getLicenceType());
        ResponseEnum.BAD_LICENCE_TYPE.assertNotNull(type);
        // query logic ...
    }

    @Transactional(rollbackFor = Throwable.class)
    public LicenceAddRespData addLicence(LicenceAddRequest request) {
        Licence licence = new Licence();
        // set fields ...
        this.save(licence);
        return new LicenceAddRespData(licence.getLicenceId());
    }

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

Running the service shows that missing licences, invalid licence types, 404 routes, unsupported HTTP methods, parameter validation errors, and database errors are all captured and returned with a consistent code/message payload.

Production Considerations

In production, unknown or servlet exceptions are mapped to a generic "Network error" message to avoid leaking internal details.

Conclusion

Combining Assert utilities, enum‑based error codes, and a centralized @ControllerAdvice handler dramatically reduces boilerplate try‑catch code and provides uniform error responses. For advanced scenarios (security, gateway failures, remote calls) additional handlers may be needed, but the presented pattern forms a solid foundation for clean backend development.

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.

Javaspringmybatis-plusAssertexceptionhandlingControllerAdvice
Programmer DD
Written by

Programmer DD

A tinkering programmer and author of "Spring Cloud Microservices in Action"

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.