DDD 落地的那些障碍:我踩过的坑和还没解决的问题
// 目录 · contents
大概在 2020 年底,我们团队开始认真研究 DDD,动机是微服务拆分越来越混乱——每次需求来了,不知道新的业务逻辑该放哪个服务里,服务之间的耦合也越来越多。DDD 看起来能解决这个问题。
读了《领域驱动设计》(Evans 那本)和《实现领域驱动设计》(IDDD),花了两三个月。理论上理解了大部分概念之后,在具体项目里落地时还是碰了很多钉子。这篇文章记录一些实际遇到的障碍,不是教程,更像是踩坑日记。
最难的不是技术,是建立统一语言
Evans 在书里强调”通用语言”(Ubiquitous Language)是 DDD 的基础——业务、产品、开发用同一套词汇描述同一件事,避免理解偏差。
听起来容易,实际做很难。
我们在做订单中心重构时,开了一个领域建模的工作坊,叫来了产品、业务、后端、测试。第一个问题就卡住了:什么叫”订单”?
产品说”用户提交购物车就是订单”,业务说”只有付款之后才算订单”,测试说”只要有订单号就是订单”,后端说”数据库里 orders 表里有一条记录就算”。这四个理解描述的是同一个东西吗?不一定。
花了整整一个上午,我们才建立起相对统一的认识:用户提交购物车之后创建的叫”预订单”,完成支付后状态变为”确认订单”,这两个在代码里是同一个实体,但处于不同的生命周期阶段。
这个过程很痛苦,但很有价值。建立通用语言不是开一次会就能搞定的,需要在日常沟通中不断强化。
聚合边界的划分没有标准答案
DDD 里的聚合(Aggregate)是一组业务规则上密切相关的对象,由聚合根(Aggregate Root)统一管理生命周期和一致性。
问题是:边界怎么划?
我们的电商系统里,订单包含订单头和订单行,这个很清楚。但优惠券呢?用户使用优惠券下单,优惠券的核销是”订单聚合”的职责,还是”优惠券聚合”的职责?
Evans 书里的原则是:用事务一致性来划聚合边界——如果两个对象需要在同一个数据库事务里保持一致,它们就应该在同一个聚合里。
按这个原则,优惠券核销和订单创建应该放在同一个聚合……但订单聚合一旦把优惠券也包进来,这个聚合就太大了,性能也会受影响(每次操作都要加载整个聚合)。
最后我们的解法是:订单聚合只记录”使用了哪张优惠券”(只保存
couponId),不直接操作优惠券对象。优惠券核销通过领域事件(Domain
Event)异步驱动——订单创建后发送
OrderCreatedEvent,优惠券服务监听并处理核销。这样换来了聚合边界清晰,代价是一致性变成最终一致,需要处理核销失败的补偿。
这是个合理的取舍,但”最终一致性足够了”这个判断需要和业务对齐,不是技术单方面能决定的。
仓储(Repository)和 ORM 的矛盾
DDD 里的仓储接口应该面向领域对象,不应该暴露任何持久化细节:
1 | |
但实际用 JPA/MyBatis 实现时,问题就来了。
JPA 的懒加载(Lazy Loading)会导致聚合在不该触发数据库查询的地方触发查询(经典的 N+1 问题)。如果把聚合内的关联都改成 Eager,又会每次加载大量不需要的数据。
MyBatis 相对灵活,可以手写 SQL,但这意味着仓储实现里充斥着 SQL,和”不暴露持久化细节”的目标南辕北辙。
我们后来妥协的方案:在仓储接口上定义领域语义,实现层用 MyBatis 手写 SQL,接受实现层”知道”数据库结构这个事实。仓储的职责变成”提供领域对象视角的查询接口”,而不是严格的持久化透明。
纯粹主义者可能不满意,但在实际项目里这个妥协是合理的。
领域事件的处理比预想的复杂
领域事件(Domain Event)是 DDD 解耦聚合间交互的主要手段,但实现起来有不少细节问题。
最烦的问题:事件在事务提交前还是提交后发布?
如果事务提交前发布,下游消费者可能读到还没有持久化的数据。如果事务提交后发布,如果发布失败(比如 MQ 不可用),事件就丢了,数据库和消息队列的状态不一致。
我们用了”事务性发件箱模式”(Transactional Outbox):事件先写入数据库的 outbox 表(和业务数据在同一个事务里),由单独的后台进程扫描 outbox 表把事件推送到 MQ。这样保证了”要么事件不丢,要么业务数据不变”的原子性。
代价是额外的 outbox 表扫描开销,以及更复杂的部署和监控。
还没解决的问题
坦率地说,有几个问题我们现在还没有好答案:
跨服务的查询:DDD 提倡聚合内部保持强一致,聚合间异步。但有些查询天然需要跨多个聚合的数据组合,用事件驱动同步又很重,怎么处理?我们现在的方案是用 CQRS 分离读写,读侧维护专门的查询视图,但维护多个读模型的成本不低。
团队规模和 DDD 的适配:DDD 的建模过程、领域专家协作,在一个 3-4 人的小团队里性价比存疑。我们推行这套方法论时,遇到了”这样做太重了”的抱怨,确实,DDD 更适合边界复杂、需要多人长期维护的系统。
DDD 和 CRUD 的边界:不是所有的业务逻辑都值得 DDD 建模。简单的 CRUD 操作套一层 DDD 只会增加代码量和理解成本。判断哪些地方值得用 DDD,哪些地方直接 CRUD,这个判断经验至今积累中。
如果对 DDD 感兴趣,推荐的阅读顺序:先读《领域驱动设计精粹》(Vaughn Vernon 的新书,比 Evans 那本薄,适合入门),再读 IDDD(实现细节),最后才是 Evans 的原著(概念体系最完整,但有些地方对现代架构有点过时)。