业务异常应使用自定义BusinessException而非RuntimeException,以支持错误码枚举、国际化提示、统一JSON响应及全链路治理。

为什么不能直接 throw new RuntimeException("xxx")
业务异常不是系统故障,而是流程中明确预知的失败分支,比如「用户余额不足」「订单已取消」「手机号已被注册」。用 RuntimeException 丢出这类信息,会导致错误码缺失、提示硬编码、上游无法区分处理——调用方只能靠 getMessage() 字符串匹配,一改文案就崩。
- 错误码(如
USER_BALANCE_INSUFFICIENT)必须可枚举、可查文档、可被监控系统采集 - 提示信息需支持国际化,不能写死在异常构造里
- HTTP 接口返回时,需自动映射为标准 JSON 结构(如
{"code": "USER_BALANCE_INSUFFICIENT", "message": "余额不足,请先充值"})
定义业务异常类的关键字段和构造方式
核心是把 code 和 args(占位符参数)作为必传项,消息体延迟到获取时才渲染,避免日志或序列化时提前绑定上下文。
public class BusinessException extends RuntimeException {
private final String code;
private final Object[] args;
public BusinessException(String code, Object... args) {
super(code); // 父类 message 仅存 code,便于日志追踪
this.code = code;
this.args = args != null ? args : new Object[0];
}
public String getCode() {
return code;
}
public String getMessageTemplate() {
return code; // 实际项目中可从 ResourceBundle 或配置中心读取模板
}
public String getLocalizedMessage() {
String template = getMessageTemplate();
return template != null ? String.format(template, args) : code;
}
}
- 不重写
getMessage(),保留父类行为(返回code),方便 ELK 日志按字段提取 -
getLocalizedMessage()负责拼装最终面向用户的提示,由统一的 i18n 工具接管更稳妥 - 禁止在构造函数里调用
String.format,否则丢失原始参数,无法做动态翻译或审计
Spring Boot 中如何让 @ControllerAdvice 捕获并标准化返回
关键不是“捕获异常”,而是确保所有控制器抛出的 BusinessException 都走同一套响应封装逻辑,且不干扰其他异常(如 IllegalArgumentException)。
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(BusinessException.class)
public ResponseEntity<ApiResponse> handleBusinessException(BusinessException e) {
String message = e.getLocalizedMessage(); // 此处可接入 i18n
return ResponseEntity.badRequest()
.body(ApiResponse.error(e.getCode(), message));
}
}
- 必须显式声明
@ExceptionHandler(BusinessException.class),不能笼统捕获RuntimeException,否则会吞掉本该 500 的真实错误 -
ApiResponse是你自己的 DTO 类,字段应固定为code、message、timestamp,不要加data或success字段混淆语义 - HTTP 状态码别硬写 200,业务失败统一用
400 BAD_REQUEST,除非你明确需要 409 或 422
容易被忽略的三个细节
很多人写了异常类、配了全局处理器,上线后还是出问题,往往卡在这几个地方:
- Feign 客户端默认不反序列化错误响应体,远程调用抛
BusinessException后,本地只收到FeignException,需自定义ErrorDecoder解析 body 中的code字段再重新 throw - MyBatis 的
@Select方法若返回null表示“未查到”,但业务上可能是“用户不存在”,这时不能直接 throw 异常,要由 service 层判断后主动 throw,否则 DAO 层污染了业务语义 - 单元测试里用
assertThrows验证异常时,别只断言getMessage(),必须验证getCode()字段,否则重构提示文案时测试会失效
错误码不是字符串常量,是业务契约的一部分;它得能被前端 switch,能被运维告警,能被客服系统识别。写一个 BusinessException 很快,让它真正活在整条链路里,才是难点。

















