Mastering Unified Exception Handling in Spring Boot: Clean Code with Assertions
This article explains how to replace repetitive try‑catch blocks with a unified exception handling framework in Spring Boot, using @ControllerAdvice, custom Assert utilities, and enum‑based error codes, while showing concrete code examples, configuration steps, and runtime results.
During Java backend development it is common to litter the code with try { … } catch { … } finally { … } blocks, which makes the source noisy and hard to read. The author first demonstrates the problem with two screenshots of a "ugly" controller full of try‑catch statements and a cleaner version that delegates error handling.
To solve this, the article introduces Spring 3.2’s @ControllerAdvice together with @ExceptionHandler. These annotations allow a single class to intercept exceptions thrown from any controller, eliminating the need for duplicated error‑handling code in each controller or service.
Replacing try‑catch with assertions
The author proposes using assertions to validate business conditions instead of explicit if checks that throw exceptions. Spring’s org.springframework.util.Assert is shown as a reference:
public abstract class Assert {
public static void notNull(Object object, String message) {
if (object == null) {
throw new IllegalArgumentException(message);
}
}
}A custom Assert interface is then defined, providing default methods that create a BaseException when the assertion fails:
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);
}
}
}Two enums are introduced to hold error codes and messages. ResponseEnum defines business‑specific errors, while CommonResponseEnum and ArgumentResponseEnum cover generic cases such as server errors or validation failures:
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;
// getters omitted
}Unified exception handler
The core of the solution is the UnifiedExceptionHandler class annotated with @ControllerAdvice. It defines several @ExceptionHandler methods, each returning a structured ErrorResponse containing a numeric code and a human‑readable message:
@Slf4j
@Component
@ControllerAdvice
@ConditionalOnWebApplication
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 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 three groups: (1) pre‑controller errors such as 404 or unsupported HTTP methods, (2) custom business exceptions, and (3) unknown runtime exceptions. For production environments the handler masks detailed stack traces and returns generic messages like "Network error".
Applying the pattern in a real service
An example LicenceService shows how to use the custom Assert and enum‑based errors:
@Service
public class LicenceService extends ServiceImpl<LicenceMapper, Licence> {
@Autowired
private OrganizationClient organizationClient;
public LicenceDTO queryDetail(Long licenceId) {
Licence licence = this.getById(licenceId);
checkNotNull(licence);
OrganizationDTO org = ClientUtil.execute(() -> organizationClient.getOrganization(licence.getOrganizationId()));
return toLicenceDTO(licence, org);
}
public QueryData<SimpleLicenceDTO> getLicences(LicenceParam licenceParam) {
String licenceType = licenceParam.getLicenceType();
LicenceTypeEnum licenceTypeEnum = LicenceTypeEnum.parseOfNullable(licenceType);
// assert non‑null
ResponseEnum.BAD_LICENCE_TYPE.assertNotNull(licenceTypeEnum);
LambdaQueryWrapper<Licence> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(Licence::getLicenceType, licenceType);
IPage<Licence> page = this.page(new QueryPage<>(licenceParam), wrapper);
return new QueryData<>(page, this::toSimpleLicenceDTO);
}
@Transactional(rollbackFor = Throwable.class)
public LicenceAddRespData addLicence(LicenceAddRequest request) {
Licence licence = new Licence();
licence.setOrganizationId(request.getOrganizationId());
licence.setLicenceType(request.getLicenceType());
licence.setProductName(request.getProductName());
licence.setLicenceMax(request.getLicenceMax());
licence.setLicenceAllocated(request.getLicenceAllocated());
licence.setComment(request.getComment());
this.save(licence);
return new LicenceAddRespData(licence.getLicenceId());
}
private void checkNotNull(Licence licence) {
ResponseEnum.LICENCE_NOT_FOUND.assertNotNull(licence);
}
// conversion methods omitted for brevity
}Database seed statements illustrate the initial data set, and a series of screenshots demonstrate how the application returns structured error responses for missing licences, invalid licence types, 404 routes, unsupported HTTP methods, and validation failures.
Configuration for 404 as an exception
To make Spring throw NoHandlerFoundException for unmapped URLs, the following properties are added to application.properties:
spring.mvc.throw-exception-if-no-handler-found=true
spring.resources.add-mappings=falseAfter this change, a request to a non‑existent endpoint results in a captured SERVER_ERROR response that the front‑end can translate into a user‑friendly 404 page.
Result and best practices
The final summary states that by combining assertions, enum‑based error codes, and a global @ControllerAdvice handler, most exception scenarios are covered with minimal boilerplate. The author notes that additional concerns such as security, gateway fallback, or remote‑call errors would require separate handlers, but the presented pattern forms a solid foundation for clean, maintainable backend code.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
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.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
