主题
DDD 生产踩坑:现象、证据、根因与处置
一句话:DDD 落地失败很少是「概念不懂」,多是动作变形:把分层当 DDD、聚合越圈越大、规则又漏回 Service。每个坑都留下了可查的证据,先取证再改模型,比直接重写可靠。
为什么需要
同一种「用了 DDD」的项目,可能处在完全不同的状态:有的边界清楚、聚合可测;有的只是把包名改了,事务横跨五张表,事件靠手工补发。靠代码评审时的主观印象判断不可靠,靠「我们用了聚合」这类陈述更不可靠。
把坑写成现象 → 证据 → 根因 → 处置,是为了让团队在出问题时先拿数据,再决定改代码还是改边界。下面六类是实践中最常见的变形。
核心机制
六个坑可以归到四个失效面:
| 失效面 | 表现 | 先看什么 |
|---|---|---|
| 边界 | 上下文说不清、服务按层拆、多服务写同一张表 | 数据归属与调用链跳数 |
| 模型 | 聚合过大、贫血模型复活、规则散落 | 聚合行数/并发冲突率、Service 里的状态判断 |
| 一致性 | 事件丢、重复消费、投影延迟 | Outbox 积压、幂等键、消费 lag |
| 依赖 | 领域层依赖框架、仓储扛报表 | ArchUnit、仓储方法名、慢查询 |
证据的原则:能指向具体行、具体表、具体指标。没有证据的「感觉模型不对」不是问题描述。
最小上手
先建立三条基线,后面的判断都围绕它们:
bash
# 1. 依赖基线:领域层是否干净
mvn -q test -Dtest=ArchitectureTest
# 2. 模型基线:领域层的规则入口和框架依赖
rg -n "setStatus|setAmount|@TableName|@Mapper" src/main/java/**/domain || true
# 3. 一致性基线:Outbox 是否积压
mysql -e "SELECT COUNT(*) pending, MIN(created_at) oldest FROM outbox WHERE published_at IS NULL;"把三条命令的输出贴进故障记录。没有基线,排障会退化成猜测。
生产动作
坑 1:把 DDD 当四层模板
现象:项目声称用了 DDD,代码里只有 controller/service/domain/dao 四个包;问「限界上下文怎么划」,答不上来;同一个「客户」在三个模块各有一份模型。
证据:rg -n "class Customer" --glob "*.java" 出现多份且字段不同;rg -n "class .*Service" src/main/java 里业务判断远多于领域方法;上下文画布不存在或超过半年未更新。
根因:只搬了战术外壳,没有做战略设计。包结构是技术分层,不是业务边界。
处置:选一条最痛的业务流做一次事件风暴,产出 01 篇的上下文画布和映射表;先把一个核心聚合的规则移进模型,用测试固定住,再推广。不要先拆服务。
坑 2:聚合越圈越大
现象:一个聚合根挂十几个子对象,一次事务更新十几张表;高峰期同一订单的请求大量超时,日志里是锁等待或乐观锁冲突。
证据:performance_schema.data_lock_waits 与 data_locks 显示多个事务等同一行;SHOW ENGINE INNODB STATUS\G 的 LATEST DETECTED DEADLOCK 指向同一批表;optimistic_lock_failures_total 持续上升;单事务持有时长 P99 明显高于接口 P99。
根因:把「业务上相关」当成「必须强一致」。订单和库存相关,但不必在同一个事务里改。
处置:按「必须一起变」重划聚合:订单项留在订单内,库存、积分、通知改为订阅 OrderPaid 异步处理。热点聚合单独拆出,读写分离。先改一致性边界,再调 innodb_lock_wait_timeout(默认 50 秒,MySQL 8.0)。
坑 3:贫血模型换个壳复活
现象:实体、值对象、聚合、仓储都建了,但领域对象只有 getter/setter;OrderService.pay(order) 里做状态校验和金额计算。
证据:rg -n "\.setStatus\(|\.setAmount\(" src/main/java 大量命中;rg -n "if \(.*status" src/main/java/**/application 出现状态流转;领域类的测试全是 getter/setter 断言。
根因:建模目标停留在「把数据类改名」,没有把「规则归属」当验收标准。
处置:把状态流转、金额计算、不变量校验移进聚合方法,用 02 篇的测试固定;Code Review 只认一条:规则在领域层能否找到执行点。order.pay(now) 在 Service 里出现就打回。
坑 4:CRUD 模块硬上 CQRS + 事件溯源
现象:字典、配置、纯增删改查模块也拆了写模型和读模型,代码量翻倍;读模型经常滞后,用户保存后刷新看不到;事件表每天增长,但没人重放。
证据:统计该模块的领域事件数量与查询需求,事件只用于「记录一下」;projection_lag_seconds 经常超过业务可接受窗口;事件表行数远大于业务表,且没有快照或归档策略。
根因:把架构风格当成熟度标志,没有先问「业务是否需要审计、重放、读写分离」。
处置:按子域分类施策:核心域用完整 DDD;支撑域保留聚合和本地事务;通用域直接用简单分层。已经上的 CQRS,若读模型收益不足,退回单模型;事件溯源只在有审计/重放硬需求时保留。
坑 5:通用语言没人维护
现象:建模时定了术语表,三个月后代码里「订单/交易/单据」混用;接口 DTO 出现在领域层;业务方说的「提单」在代码里叫 submitForm。
证据:rg -n "SubmitForm|OrderStatusNotifyDTO|OrderDTO" src/main/java/**/domain 有命中;同一概念在类名、表名、接口文档里三种写法;术语表最后一次修改早于最近三次需求变更。
根因:术语表没有进评审流程,也没有 owner;语言漂移没人拦。
处置:术语表进仓库,变更走 PR;每个上下文指定业务 + 技术双 owner;把「领域层出现传输对象命名」加入 ArchUnit 或评审清单。先统一命名,再改逻辑,避免一次改两件事。
坑 6:仓储被当查询神器
现象:仓储接口方法越来越多:findTop10ByStatusAndTimeRange、sumAmountByUser、searchByKeyword;报表查询走仓储,慢查询拖垮写库。
证据:rg -n "find|search|sum|count" src/main/java/**/domain/*Repository.java 出现报表语义;慢查询日志(long_query_time 默认 10 秒,MySQL 8.0)里大量聚合/排序 SQL;performance_schema.events_statements_summary_by_digest 显示报表 SQL 扫描行数远大于返回行数。
根因:写模型和读模型混在一个接口里,聚合被查询需求绑架。
处置:仓储只保留聚合生命周期需要的 findById、save、delete;报表、列表、检索走独立读模型(宽表、视图、ES)。判断标准:仓储方法签名里出现「报表/统计/搜索」语义,就已经越界。
落地节奏
第 1–2 周:统一语言(术语表 + 命名 + 评审)
第 3–6 周:选一个最痛的核心聚合,规则进模型,补不变量测试
第 7 周起:跨聚合事件化(Outbox + 幂等),再补上下文映射和防腐层不要一上来全流程改造。先用一个模块的返工率和缺陷率证明收益,再推广。
边界
- 这些坑不适用于纯 CRUD:没有复杂规则时,简单分层就是正确选择,硬套 DDD 本身就是坑 4。
- 证据有成本:小团队不必上全套监控,但 Outbox 积压、乐观锁冲突率、领域层依赖检查这三条成本很低,值得常备。
- 不要用 DDD 解释所有故障:数据库连接池耗尽、GC 停顿、网络分区不是模型问题,先按对应领域排查。
- 重构边界要一次到位:只改包名、不改数据归属和事务边界,会把坑 1 变成长期债务。
讲清楚
DDD 的坑几乎都能归到四个失效面:边界、模型、一致性、依赖。边界错了,表现为多服务写同一张表、一次需求跨五个服务;模型错了,表现为聚合过大导致锁等待,或者规则又漏回 Service;一致性错了,表现为 Outbox 积压、重复消费、投影延迟;依赖错了,表现为领域层有框架注解、仓储扛报表。处理顺序是先取证:ArchUnit、
rg、data_lock_waits、Outbox 查询、慢查询日志,然后判断是边界问题还是实现问题。边界问题重画上下文,实现问题改代码。不要先用调大超时、手工补数据把现象压下去,那只会把模型问题拖成长期债务。
初稿 2026-10 | 最后修订 2026-10