摘要:DDD 落地难,难在把“责任”和“时机”揉进同一个 Service。本文主张把它切开:模型、规则、事件承载“责任”,编排与一致性收管“时机”,再用一条固定流程咬合——业务只声明,时机交给框架。
本文不介绍某套框架有多少个模块,也不带你去逐行走查代码。我想聊一个观点:DDD 之所以难落地,不是因为它难懂,而是大家习惯把太多的“责任”和“时机”揉在一个类里写。
我认为务实的做法,是把一次业务请求切开成几个各司其职的部分,每部分只回答一个问题,再由一条固定的流程把它们咬合起来。这套“怎么切、每部分装什么”的方法论,才是真正值得沉淀的东西。
一、DDD 落地难,难在把“责任”和“时机”揉在一起
很多团队学完 DDD 回到项目,写出来的代码依然“贫血”。先别急着贴标签,我们看看真实的 Service 长什么样:
public void pay(Order order, PaymentInfo info) {
// ① 校验“已发货订单不能再支付”(这是一条业务责任)
if (order.getStatus() == SHIPPED) {
throw new IllegalStateException("已发货订单不能支付");
}
// ② 真正改状态(这也是责任)
order.markPaid(info);
// ③ 立即落库(这是“时机”——提交事务)
orderRepository.save(order);
// ④ 立刻发短信、送积分(既是责任,又被绑死在“支付这一刻”这个时机)
smsService.notify(order);
pointsService.grant(order);
}
你发现问题了吗?校验金额是“责任”,落库是“时机”,发短信是“责任”,发事件又和“支付这一刻”这个时机死死绑在一起。 它们全挤在一个方法里,顺序即一切。
于是副作用出现了:你想改“校验”这条逻辑,却不得不先理解整段方法的执行顺序;你想知道“支付到底会触发哪些下游”,只能人肉去读 pay() 里每一行;你想把它从“支付”延后到别处,根本做不到——因为时机已经写死在方法里了。
这就是“责任与时机揉在一起”的代价:每一段都有两重含义,改动任何一处都要通盘理解,系统因此越长越难动。
DDD 书讲清了“领域模型”长什么样,却很少讲清另一件事——那些驱动模型的动作(何时校验、何时发事件、事务边界、失败兜底)到底该放哪。于是人们把所有动作堆进 Service,把领域层写成 getter/setter。这不是“不会 DDD”,而是没有一个把责任切开的框架。
所以我把方法论的第一步放在“切开”:先分清每一行代码到底是在表达责任,还是在决定时机,再把它们分别归位。
二、我的核心观点:架构的本质是“划分 + 咬合”
好的架构(尤其在 DDD 落地里)本质只有两件事:
- 划分:把一次请求里不同性质的责任,拆成各自独立的“部分”。
- 咬合:用一条确定、固定、可被托管执行的流程,把这些部分按顺序拼起来。
划分得好不好,有个实用标准——每部分能否独立回答一个问题:领域模型回答“业务状态和行为是什么”,业务规则回答“有哪些不变量”,领域事件回答“会带来哪些副作用”,应用编排回答“按什么顺序、什么时机做”,一致性回答“落库和发消息如何不互相拆台”。
当每部分只回答一个问题,它就能单独被阅读、测试、复用;而把“时机、顺序”抽走交给统一模板后,业务代码会异常干净。
对应到上面的病根,这五部分其实在做一件事:把“责任”按领域语义分到前三层(模型/规则/事件),把“时机”统一收进编排层与一致性层。 读者在下面读每一层切分时,不妨想想——这一刀,切掉的是责任还是时机?下面用一个订单系统当载体,讲每个部分装什么。
三、怎么切:五个各司其职的部分
3.1 领域模型:只回答“业务是什么”
Order 聚合根承载状态(status、orderItems)和业务行为(pay、ship),但有一条容易被忽略的纪律:聚合根只表达业务意图,绝不亲自碰数据库、发消息,只“记录操作 + 收集事件”:
public Order(OrderInitData data, Long orderId) {
this.recordOperation(OrderOperationRegistry.PLACE); // 只声明“我要做一次下单”
this.collectEvent(OrderCreatedEvent.buildEvent(this)); // 只声明“发生了一个事实”
}
“记录操作”“收集事件”都只是在对象上登记,不触发任何外部调用。聚合根是“说事实”的地方,不是“做事情”的地方——领域层因此可脱离框架单独测试。
3.2 规则层:把不变量从方法里“请”出来
传统代码里,“已支付订单不能再改”这类不变量散落在各方法的 if 里,没人说得清订单有哪些约束,重构极易误删。
我们把不变量从方法里“请”出来,变成聚合根上一份可枚举的规则契约,每条规则是无状态纯函数,还能带激活条件:
public class OrderRule extends EntityRule<Order> {
public OrderRule() {
this.addRule((s, old) -> s.getTotalPrice() != null
&& s.getTotalPrice().compareTo(BigDecimal.ZERO) > 0, TOTAL_PRICE_ERROR);
}
}
关键在“定义与执行分离”:规则只负责定义,“何时调用”交给编排层。于是“订单有哪些不变量”从“散落各处”变成“一份可见的契约清单”。
3.3 事件层:把副作用变成看得见的契约
支付后要“发短信、送积分、同步数据”,这些副作用若直接写进 pay(),领域层就脏了,也没人能一眼看出“一次支付触发哪些下游”。
我们让领域层只收集“事实”,“事件该让谁去做”由一份集中登记表声明:
evtManager.registerSubscriber("es", OrderDataSyncEvent.class, esProjectionHandle);
evtManager.registerSubscriber("sms-notify", OrderPaidEvent.class, smsNotifyHandle);
一眼就能读出业务侧画:“订单数据变了→同步 ES;订单支付了→发短信送积分”。把副作用显式化,让系统的连锁反应不再是黑盒,而是一份能读、能盘点的清单——这是我认为最有价值的一点。
3.4 编排层:把“时机”交给同一个模板
前三层只回答了“是什么、约束什么、产生什么事件”。真正难的“按什么顺序、什么时机执行”,我们把它从业务里抽出来,交给一个固定模板。顺序几乎永远固定:执行业务逻辑 → 校验规则 → 落库 → 发事件 → 清理状态。既然顺序固定,为何每个方法都重写?我们收敛成对所有命令统一的 execute:
public <ID, T> T execute(T agg, IRule<?> rule, IRepository<ID, T> repo, Consumer<T> domainLogic) {
domainLogic.accept(agg); // ① 执行业务逻辑(你写的)
if (rule != null && !agg.satisfiesRule(rule)) {
agg.throwBrokenRuleException(); // ② 校验规则(落库前)
}
persistAndDispatch(agg, repo); // ③④ 落库 + 分派事件
agg.clearWorkUnitState(); // ⑤ 清理中间状态
return agg;
}
注意两个时机讲究:校验在落库前(失败则数据库不被污染),事件清理在分发后(不残留进下次操作)。应用层于是极简:
public Order placeOrder(CreateOrderInput input) {
return super.execute(orderFactory.create(input), orderRule, orderRepository, t -> { });
}
创建用 Factory 建聚合、修改用 Updater 改聚合,但落到“校验+落库+发事件”永远只有一行 execute。“改法”与“固定动作”被切开,这正是领域层回归干净的根本。(框架还提供 tryExecute 试跑:跑同样的校验但不落库、不发事件,很适合做预校验接口。)
3.5 一致性层:落库与发消息不该二选一
订单落库了、发往 MQ 的事件却丢了(或反之),是分布式绕不开的难题。我们的切法是发件箱(Outbox):把“发消息”也变成一次“落库”。
// 同一数据库事务:写聚合 + 写 outbox 表(PENDING)
txOps.execute(() -> {
repository.save(aggregateRoot);
outboxStore.store(serialize(aggregateRoot.getDomainEvents()));
});
eagerPublisher.publishAfterCommit(stored); // 事务提交后再推送
两者在同一事务里,要么都成、要么都回滚;提交后才推送,即便推送失败,后台也有 OutboxRelay 轮询兜底、补偿重发。对业务代码这一切仍被封装在那行 execute 里,应用层无感。
四、五个部分如何咬合成一条流水线
把五部分连起来,一次“下单”就是一条流水线:
HTTP 入口(薄,只构造入参)
→ 应用层:Factory 建聚合(模型),聚合内登记操作、收集事件
→ 统一模板 execute:校验规则(落库前)→ 同事务落库+写 outbox → 提交后推事件 → 清状态
→ 事件回流:订阅登记表 → 同步 ES / 刷缓存 / 发短信 / 送积分
每段职责都不越界:入口层不懂业务,应用层只做“选工厂/Updater + 调 execute”,领域层只“说状态、说规则、说事件”,一致性交给发件箱,副作用由事件回流驱动。请求从进到出,没有一处是杂乱无章的 Service。
这套切法还带来三个可迁移收益:每部分都能独立测试(规则是纯函数、聚合根不依赖中间件);改动影响面可推导(副作用显式,改一个聚合根能列出影响哪些下游);换中间件不动领域代码(数据库、MQ 都是可插拔实现)。
五、什么时候你不要这么切
这套切分不是银弹:
- 极简单的 CRUD:无复杂不变量和副作用,强行分层是过度设计。
- 团队没人守纪律:它依赖“领域层不碰基础设施”“命令统一走 execute”这类约束,习惯各写各的就会烂掉。
- 要 Event Sourcing 全家桶:这里的发件箱+投影是克制的轻量方案,不覆盖重型玩法。
判断标准很简单:没有需要保护的规则、没有需要追踪的副作用,就不要为了 DDD 而 DDD。
六、写在最后
DDD 落地难,从不是因为概念难懂,而是没人替你把“责任”和“时机”分开。我反复强调的只有一句:
把“业务是什么、约束是什么、会产生什么”写在领域层,把“何时做、按什么顺序、失败怎么兜底”交给统一编排与一致性机制。
当业务代码只剩“声明”,当规则、事件、副作用都变成看得见的清单,领域驱动才真正从口号变成可落地、可测试、可推导的工程方法。至于具体的包结构、模块,不过是这套方法论的最终形态,属于实现细节。