从"遍地 try-catch"到分层治理:用 pragmatic-ddd 的异常体系治好代码洁癖
基于 pragmatic-ddd 的用户接口层、应用服务层、领域层、基础设施层 四层模型,聊聊异常该在哪一层被"接住"。文中异常类(
PragmaticException/BrokenRuleException/AclException)均来自框架真实实现。
一、为什么"到处 try-catch"让我不舒服
先说一句废话:try-catch 不是原罪,问题是它出现的地方不对。
最直观的一个毛病是重复。每个对外方法都在干同一件事:catch → 打日志 → 包统一返回。这堆逻辑跟业务没关系,却和业务代码缠在一起。哪天你想统一改日志格式或返回码规范,得挨个方法改,漏一个就是线上事故。
还有个更烦的:业务逻辑被埋了。你打开一个方法,第一眼看到的全是 try-catch,真正的"创建订单"逻辑夹在中间。读者得先把异常处理这层壳剥掉,才能看到你到底想干嘛。
团队一大,不一致的问题也来了。有人 catch 了 Exception 不打日志只返回,有人打了日志不返回,还有人直接空 catch。同一个项目,不同接口的异常表现五花八门,前端对接全靠猜。
最隐蔽的是错误暴露被掩盖 。轻一点的,空 catch 把异常整个吞了------catch (Exception e) {} 一写,日志堆栈全没,线上出问题你连从哪查都不知道。重一点的,叫"假正常":异常确实被 catch 了、日志也打了,可从外面看接口一切正常、HTTP 200、返回"成功"。监控告警多半依赖错误日志或异常指标,异常一旦在内部被消化掉,告警也就跟着失效。结果就是系统表面平静如水,里面其实早就开始出错,谁都没察觉。等用户投诉、或者对账发现数据不对,可能已经错了好几个小时。
二、按层落位:异常该在哪一层被接住?
pragmatic-ddd 把代码分成四层:用户接口层、应用服务层、领域层、基础设施层。异常处理也要顺着这个分层来分工,而不是在每个方法里乱抓。
领域层:业务规则用结构化异常表达
领域层是"业务原子零件",不依赖任何技术框架。它的异常责任只有一个------把业务不变量讲清楚。
框架给出层级清晰的异常体系:
php
RuntimeException
└─ PragmaticException // 框架所有业务异常的抽象基类
└─ RuleException // 业务规则校验异常的基类
└─ BrokenRuleException // 单条规则违反,携带 code / message / source
BrokenRuleException 的好处是自己带着错误码 。规则容器(OrderRule)校验失败时框架直接抛它,你不用再手动 new BizException(400, "xxx")。
java
// 领域层规则容器:不变量注册,校验失败自动抛 BrokenRuleException(code, message)
this.addRule(
EntityRule.of(order -> RuleCheckResult.of(order.getTotalAmount().isPositive())),
OrderRuleRegistry.ORDER_AMOUNT_POSITIVE);
规则一旦不满足,抛出来的 BrokenRuleException 就带着 code = ORDER_AMOUNT_POSITIVE 和对应消息。业务失败因此是"结构化"的,而不是散落在各处的 throw new RuntimeException("金额有问题")。
往上看异常体系的基类 PragmaticException------这是关键。因为它是所有框架已知异常的父类,你在切面里写一句 catch (PragmaticException e) 就能一网打尽,异常本身就带着结构化信息往外传,下游不用再去猜字符串。
基础设施层:技术异常直接上抛,统一映射为系统异常
基础设施层负责技术实现(Repository、ACL 网关、MQ)。这里的异常分两类处理:
第一类:本地技术操作(数据库、ES、MQ)------不需要 try-catch,直接抛。
写库、Repository.save、Materializer.materialize、发 MQ,这些本身就是技术细节。失败时由持久化/客户端框架抛出底层异常(如 DataAccessException),基础设施层不 catch,让它一路冒泡到入口层,被统一映射成 .500「系统繁忙」。
java
// 基础设施层:本地技术操作,不写 try-catch,异常直接上抛
public void saveOrder(Order order) {
orderMapper.insert(order); // SQL 报错?让它抛,入口统一转 500
esMaterializer.materialize(order); // ES 超时?同样让它抛
}
这段代码没有任何 try-catch。数据库或 ES 报错,异常直接往上冒,最后由用户接口层的 @RestControllerAdvice 兜成系统异常。基础设施层对外只需要暴露"成功 / 失败"这个契约,调用方根本不必关心"到底是 SQL 报错还是 ES 超时"。
我倾向于不在这层 catch,原因很简单:本地技术失败通常没有降级余地------写不进去就是写不进去,catch 了也挽回不了。与其盖住它,不如让它老老实实暴露成统一系统异常,监控和日志都能正常收到。这正好能避免第一节说的那种"假正常"。
第二类:外部系统调用(ACL)------这是唯一需要在基础设施层局部兜底的场景。
风控、短信、库存这类外部依赖不稳定是预期内 的。框架用 AclException / AclCommunicationException 表达"外部适配失败",在网关处降级,避免外部抖动拖垮核心链路:
java
// 基础设施层:ACL 网关调用外部系统,失败局部降级
public RiskResult queryRisk(Long userId) {
try {
return riskClient.check(userId);
} catch (AclCommunicationException e) {
log.warn("风控系统暂不可用,降级为放行", e);
return RiskResult.pass(); // 非阻断降级
}
}
区别在于:本地技术失败不可降级、该暴露;外部依赖失败可降级、该容错。 别把两者混为一谈。
用户接口层(Controller):统一捕获,零 try-catch
统一异常捕获这件事,我建议放在用户接口层 ------如果你是用 Controller 做入口的话。框架官方也推荐用 @RestControllerAdvice 做异常响应映射,它本质上就是一个 AOP 切面,横切在所有 Controller 之上。
java
@RestControllerAdvice // AOP 声明式切面,统一拦截 Controller 层异常
public class GlobalExceptionHandler {
// 业务规则违反:规则容器校验失败自动抛 BrokenRuleException
@ExceptionHandler(BrokenRuleException.class)
public ResponseEntity<ErrorResponse> handleBrokenRule(BrokenRuleException e) {
return ResponseEntity.badRequest()
.body(new ErrorResponse(e.getCode(), e.getMessage()));
}
// 框架兜底异常
@ExceptionHandler(PragmaticException.class)
public ResponseEntity<ErrorResponse> handlePragmatic(PragmaticException e) {
return ResponseEntity.internalServerError()
.body(new ErrorResponse("SYSTEM_ERROR", "系统繁忙"));
}
// 兜底:未预料的 Throwable,必须打日志
@ExceptionHandler(Throwable.class)
public ResponseEntity<ErrorResponse> handleThrowable(Throwable e) {
log.error("未处理异常", e);
return ResponseEntity.internalServerError()
.body(new ErrorResponse("UNKNOWN", "系统繁忙"));
}
}
三个 ExceptionHandler 把异常分了类:业务规则失败映射成 400,框架异常映射成 500,剩下的未知异常兜底打日志再返回 500。有了它,Controller 方法里就再也看不见 try-catch 了。
还是看代码最直观:
java
@PostMapping("/orders")
public OrderVO createOrder(@RequestBody CreateOrderCommand command) {
Order order = orderWriteService.placeOrder(command); // 校验失败自动抛
return OrderVO.from(order);
}
就这么几行,全是业务逻辑:没有 try-catch、没有日志、没有返回封装。编排交给应用服务层(OrderWriteService),协议转换和异常翻译都留给用户接口层统一接管------职责清楚,代码也清爽。
推这套方案时,最容易遇到的阻力是"我怕异常没打日志",于是有人在应用服务层又包一层 try-catch 原样 throw。这层完全是多余的------全局切面已经打了完整堆栈,重复 catch 只会让同一条错误在日志里出现多次,排查时反而分不清哪一层是源头。
三、但是,try-catch 真的该彻底消失吗?
当然,也有人会走到另一个极端:既然有了统一处理,那干脆一个 try-catch 都不写了,所有异常往外抛,全靠全局切面兜底。
这想法其实挺危险的。
统一的那个切面,只解决"异常到了用户接口层之后怎么变成统一响应",它管不了"这个异常该不该发生"以及"发生了要不要做点补偿"。下面几种情况,我还是会老老实实在局部写 try-catch:
- 释放资源:IO 流、数据库连接、锁。绝大多数情况 try-with-resources 就够了,但个别场景还是得自己写 finally
- 补偿:比如发消息失败了,要把刚创建的记录删掉。这种回滚必须在局部做,切面帮不了你
- 非关键路径:记个操作日志、发个通知,失败也不该影响主流程,这时候局部 catch 掉忽略掉就行
- 调外部服务重试:网络抖一下超时了,在局部 catch 住重试一次更合适
所以核心还是那句话------这个异常是就地消化,还是往上抛给用户接口层? 想清楚再写。
| 异常性质 | 在哪处理 | 用什么异常 |
|---|---|---|
| 业务校验失败、权限不足 | 用户接口层统一处理 | BrokenRuleException / PragmaticException |
| 外部系统抖动、降级容错 | 基础设施层局部 | AclException / AclCommunicationException |
| 空指针、数组越界(Bug) | 用户接口层兜底 | 全局 Throwable 接住并报警 |
落地清单
结合 pragmatic-ddd 的分层,我自己的实践是这几条:
- 用户接口层(Controller)不写 try-catch,所有异常交给
@RestControllerAdvice,这是底线 - 领域层用结构化异常:校验失败走规则容器(自动抛
BrokenRuleException,自带 code),系统异常继承PragmaticException - 基础设施层只对外部调用做局部兜底,降级用
AclException表达,别让外部抖动污染核心链路 - 全局处理器做好三层映射:
BrokenRuleException→ 400、PragmaticException→ 500、兜底Throwable→ 打日志,三层缺一不可 - 日志分级,而且局部已经打过的就别在切面再打一遍,双倍日志除了添乱没别的作用
异常处理没什么银弹。绝大部分异常交给入口层统一接住,少数特殊情况(资源释放、补偿、外部重试)在离故障最近的地方处理------剩下的,就是别在没必要的地方写 try-catch。
参考资料
- pragmatic-ddd 仓库(框架异常体系与最佳实践均出自此处):github.com/pragmatic-l...