DDD 学习笔记 · 事件生命周期与 Outbox 事故复盘
日期: 2026-07-03 | 作者: sunxin(+ Cursor AI 结对复盘) 关联 ADR: ADR-011 关联规则:
.cursor/rules/ddd-repository-update.mdc关联代码:ContentAggregateRepository/ChatAggregateRepository/SecurityAggregateRepository/MatchingAggregateRepository/ActivityAggregateRepository/DomainEventPublisherAdapter/PostApplicationService
TL;DR
- 一句话事故:5 个聚合根仓储在
save()尾部主动aggregate.clearDomainEvents(),配合项目的 Transactional Outbox 事件发布机制,会导致领域事件在进入 Outbox 表之前就在内存里被清空 → 事件永久丢失(Outbox 兜底、@Transactional都救不回来)。 - 一句话根因:把"事件生命周期管理"这个应用编排职责错误下放到"数据翻译层"(Repository);老维护者写这句 clear 时脑海里的模型是"发出去就清空",但 Outbox 模式引入后
publishAll的语义从"发布"变成了"入库",clear抢跑一步就把 Outbox 打穿。 - 一句话修复:Repository 里所有
aggregate.clearDomainEvents()全部删除;事件生命周期统一由 ApplicationService / DomainService 层以"三行式"写法负责:save → publishAll → clear。 - 一句话教训:善意的"贴心保底"是最危险的耦合 —— 它把跨层职责悄悄下沉,还让"看起来能自我保护"的假象骗过 Code Review。
目录
- 1. 事故场景(业务视角)
- 2. DDD 概念地图
- 3. 项目原有设计错在哪里(错的那版)
- 4. 时序图:事件是怎么被"抢跑清空"的
- 5. 为什么 Outbox 救不了 & 为什么
@Transactional也救不了 - 6. 修复方案
- 7. DDD 收获(教学篇)
- 8. 防守清单:如何避免再犯
- 9. 附录
1. 事故场景(业务视角)
1.1 用户看到的现象(假设 bug 被触发)
想象一下这个再普通不过的用户行为:
用户 A 在小程序发了一条新动态 "今天去了外滩🌆"。他看到了页面加载完成、跳回列表页,也看到了自己刚发的这条内容 —— 一切正常。
但屏幕看不到的地方应该发生一系列事情:
| # | 下游动作 | 谁做 | 应该由什么触发 |
|---|---|---|---|
| 1 | 给 A 的粉丝推送"你关注的人发了新动态" | 通知子域 | PostPublishedEvent |
| 2 | 更新"今日新增动态数"数据看板 | 数据统计子域 | PostPublishedEvent |
| 3 | 把这条帖子加入推荐引擎候选池 | 推荐子域 | PostPublishedEvent |
| 4 | 首页 feed 缓存失效重建 | 缓存子域 | PostPublishedEvent |
| 5 | 触发内容安全审核(同步/异步) | 审核子域 | PostPublishedEvent |
结果:PostPublishedEvent 永远没有被发出去。上面 5 件事一件都没发生。
1.2 为什么这个 bug 极难发现
这是本次事故最值得记录的地方 —— 它有着"教科书级"的隐蔽性:
- ✅ 主流程无异常:
posts表 INSERT 成功、事务提交、Controller 返回 200 - ✅ 日志"骗人":
log.info("成功保存内容聚合根: postId=xxx")看起来一切都好 - ✅ 单接口测试通过:APP 端能看到帖子、能刷新出来
- ✅ 数据库层无脏数据:查询能查到帖子,没有 duplicate key
- ❌ 只有当下游功能上线且被人对账才会被质疑:"咦,为什么粉丝没收到新动态推送?"
排查这类 bug 时容易被"表象"骗到主流程里绕圈子 —— "帖子明明发出去了"、"数据也在库里"、"响应也返回 200 了"。真正的凶手藏在跨聚合、跨限界上下文的事件传播链里。等到有第一个下游子域开始联调"为什么没收到事件",才会往上游追。
1.3 影响面(如果没有及时发现)
按当前项目状态盘一遍:
| 聚合根 | 事件 | 抢跑者 | 后果 |
|---|---|---|---|
ContentAggregate.publishPost | PostPublishedEvent | ContentAggregateRepository.save | 发帖后所有下游子域断电 |
ChatAggregate.sendMessage | MessageSentEvent | ChatAggregateRepository.save | Chat 目前没接入 App 层,一旦接入立刻炸 |
ActivityAggregate.* | 6 个活动事件 | ActivityAggregateRepository.save | 老维护者已经把 update 里的 clear 注释掉躲过一劫,但 save 里还留着,创建新活动时 ActivityCreatedEvent 丢失 |
SecurityAggregate / MatchingAggregate | 同上 | 同上 | 一旦有事件被 add,立刻炸 |
当前只有 ContentAggregate.publishPost 是每天都在触发的"活雷" —— APP 每发一条帖子都会丢失一次 PostPublishedEvent。
2. DDD 概念地图
在讨论根因之前,先把这个事故涉及的 DDD 概念摆清楚。如果你对下面这些概念都熟,可以跳过本节。
2.1 聚合根(Aggregate Root)
一群业务上必须"一起改、一起校验、一起持久化"的对象聚在一起,选一个当"根"对外暴露 —— 这个根就是聚合根。它守护"业务不变式"(例如"帖子必须有作者"、"活动人数不能超过上限")。
ContentAggregate(帖子聚合根)
├── postId: PostId
├── authorId: UserId
├── content: String
├── status: PostStatus
├── likeCount / favoriteCount / ...
├── domainEvents: List<DomainEvent> ← 用来收集领域事件的临时容器
└── 业务行为:publishPost() / like() / delete() / ...2.2 领域事件(Domain Event)
聚合根在执行业务行为的过程中,"顺便"产生的业务事实通知 —— 用过去时命名("帖子已发布"、"成员已加入")。
// ContentAggregate.publishPost() 里
public void publishPost() {
this.status = PostStatus.PUBLISHED;
this.publishedTime = LocalDateTime.now();
addDomainEvent(new PostPublishedEvent(this.postId, this.authorId));
// ↑ 只是"记录",不是"发送"
}关键:聚合根不知道、也不关心这个事件将被谁消费、通过什么技术发出去。它只负责"记录事实"到 domainEvents 列表。
2.3 Repository(仓储)
DDD 里 Repository 是"聚合根 ↔ 持久化"的翻译层:
- Domain 层定义接口(
IContentAggregateRepository),只表达业务语义("存一个帖子"、"按 postId 查帖子") - Infrastructure 层实现(
ContentAggregateRepository),把聚合根拆成 PO 存进数据库
它的唯一职责是"数据翻译",不应该有任何业务逻辑,也不应该管事件生命周期。(本次事故的核心 —— 老代码违反了这条。)
2.4 ApplicationService(应用服务)
编排层。一个 Use Case 的入口,负责:
- 拉出聚合根(
repo.findById(...)) - 调用聚合根的业务行为(
aggregate.doBusiness()) - 持久化(
repo.save(aggregate)) - 发布领域事件(
domainEventPublisher.publishAll(aggregate.getDomainEvents())) - 清理事件列表(
aggregate.clearDomainEvents()) - 组装响应
它是唯一有权协调"业务 + 持久化 + 事件"的层。
2.5 事件驱动架构里的四层:产生 → 收集 → 发布 → 消费
[聚合根业务方法] --产生--> [聚合根事件列表] --发布--> [事件总线/Outbox] --消费--> [事件消费者]
↑ ↑ ↑ ↑
业务不变式变化时 ApplicationService DomainEventPublisher @EventListener
顺带 record 事件 调 publishAll 时 Adapter 具体做 或 MQ 消费者每一层职责必须严格隔离。老代码违反了这一点:Repository 越权干了 ApplicationService 的活。
2.6 Transactional Outbox(事务性发件箱)模式
分布式事务的经典模式,用来解决"业务事务提交了 vs 事件已经发出去了"的两难:
- 不用 Outbox:先发事件再提交业务?失败重试怎么办?先提交业务再发事件?宕机怎么办?
- 用 Outbox:把事件的发布也变成一次 DB INSERT,跟业务操作放在同一事务里。事件不再是"发"出去的,而是"存"进 outbox 表;由异步组件把 outbox 表里的记录搬到真正的消息总线。
本项目的 Outbox 实现(DomainEventPublisherAdapter.publish):
tx { ← 业务事务
UPDATE posts SET status='PUBLISHED' ...
INSERT INTO domain_event_record ( ← ★ Outbox 表 INSERT
event_id, event_type, event_data, status='PENDING'
)
Spring EventBus.publish(evt) ← 同步试发一次
if (成功) UPDATE domain_event_record SET status='PUBLISHED'
if (失败) UPDATE domain_event_record SET status='FAILED', error='...'
}
COMMIT / ROLLBACK ← 上面所有 SQL 一起提交或回滚
(异步)SagaRecoveryTask 定时扫 PENDING/FAILED 记录,重发Outbox 模式的核心前提:事件必须先入 domain_event_record 表。所有兜底(重试、补偿、幂等)都建立在这个前提上。这个前提一旦被破坏,所有兜底机制全部作废 —— 这就是本次事故的根源。
3. 项目原有设计错在哪里(错的那版)
3.1 老维护者写代码时的心智模型(善意但错误)
想象 v0.1.0 时的心态。当时项目还没有 Outbox 模式,事件发布只是个简单的 applicationEventPublisher.publishEvent(evt)(Spring 本地事件总线,同步)。老维护者面对的问题是:
"聚合根里
domainEvents列表是个可变 List,如果我发过一次没清空,下次 save 又会重复发一遍。得记得清空。"
于是他做了个"贴心保底":
@Override
public void save(ContentAggregate aggregate) {
...
// 清除领域事件(在事件发布后)
aggregate.clearDomainEvents(); // ← "反正 save 之后就该清了,我在这里帮你清好"
...
}这个决定在当时是自洽的:
- 事件发布是同步的、幂等无所谓、也没有 Outbox
- Service 层大概率会先 publish 再 save,clear 放 save 里没什么坏处
- 甚至能"防止 Service 层忘记 clear"
3.2 Outbox 模式引入后,"贴心保底"变成了炸药
后来(v0.1.0 → 引入 Saga 的迭代里,见 docs/devlog/04-Saga与领域事件机制.md)项目上了 Outbox 模式,DomainEventPublisherAdapter.publish 的语义变了:
- 之前:
publish()是"通知消费者",同步、可失败、失败就算了 - 之后:
publish()是"事件入库",把事件存进domain_event_record表;真正的消费由异步组件保底
这个语义变化下,publishAll 的调用必须发生在 clear 之前,否则 Outbox 表拿不到事件 → 兜底机制全部作废。
但老代码的 5 个 Repository 里的 clear 没有跟着改。老维护者也没意识到"Outbox 让 clear 的位置从'无关紧要'升级成'致命顺序'"。
3.3 Service 层的"三行式"惯例与 Repository 抢跑的冲突
看看当前 PostApplicationService.publishPost 的正确写法:
@Transactional(rollbackFor = Exception.class)
public PublishPostResult publishPost(PublishPostCommand command) {
ContentAggregate aggregate = ...;
aggregate.publishPost(); // events=[PostPublishedEvent]
contentRepository.save(aggregate); // ★★★ 这里出问题
domainEventPublisher.publishAll(aggregate.getDomainEvents()); // 传入空列表!
aggregate.clearDomainEvents();
...
}Service 层作者是按标准 DDD 惯例写的,他没错。真正的凶手是 contentRepository.save(aggregate) 内部:
public void save(ContentAggregate aggregate) {
doSave(aggregate);
aggregate.clearDomainEvents(); // ★ 抢跑! events 已经被清空了
}于是 Service 层的 aggregate.getDomainEvents() 返回空列表,publishAll 空转,事件永久丢失。
两个都在"负责"清事件,一个抢跑一步,结果反而丢事件。这就是"两把锁不如没锁"的经典案例。
4. 时序图:事件是怎么被"抢跑清空"的
4.1 错的那版(改前)
要害在第 6 步:Repository 内部抢在 Service 拿事件列表之前清空了 events。所有下游都以为"发帖没有产生事件"。
4.2 修好之后(现在)
关键差异:Repository.save 只做数据落库,不碰事件;Service 拿到真实的事件列表交给 publishAll → 事件真正进了 Outbox 表 → 所有兜底机制才能生效。
5. 为什么 Outbox 救不了 & 为什么 @Transactional 也救不了
事故复盘时最容易被质疑的问题:
"我们不是有 Outbox 模式吗?不是有事务吗?为什么这两层保护都没起作用?"
因为它们保护的是不同的失败面,本次事故的失败面恰好都不在保护范围内。
5.1 Outbox 模式保护的失败面
| 失败场景 | Outbox 是否保护 | 为什么 |
|---|---|---|
| 业务成功、消费者同步消费失败 | ✅ 保护 | 事件已在 domain_event_record 表里以 FAILED 落库,SagaRecoveryTask 重试 |
| 业务成功、消费者宕机 | ✅ 保护 | 同上 |
| 业务失败回滚 | ✅ 保护 | Outbox 表 INSERT 也在同事务里,一起回滚,消费者看不到"幽灵事件" |
| 事件根本没进 Outbox 表 | ❌ 不保护 | 前提被打穿,兜底机制无从触发 |
本次事故正好落在最后一行:Repository 里的 clear 抢跑,导致事件从未进入 Outbox 表。SagaRecoveryTask 扫的是"表里已有但状态是 PENDING/FAILED"的记录 —— 表里根本没这条记录,它扫什么都扫不到。
5.2 @Transactional 保护的失败面
| 失败场景 | @Transactional 是否保护 | 为什么 |
|---|---|---|
| 业务表 INSERT 与 Outbox 表 INSERT 状态不一致 | ✅ 保护 | 事务性,要么一起提交要么一起回滚 |
| 业务成功 vs 事件已发但消费失败 | ✅ 保护 | Outbox 表 INSERT 也在事务里,回滚就一起回滚 |
| 事件根本没进 Outbox 表 | ❌ 不保护 | 事务保护的是"已发生的 SQL"的原子性,救不了"没被写出去的 SQL" |
一个更直白的类比:事务像保险柜的锁 —— 它保护"已经放进保险柜的东西",但如果你根本没把东西放进去,锁再牢固也没用。
5.3 教训:兜底机制不能替代"顺序正确性"
Outbox / 事务 / 重试 都是"横向兜底",保护的是同一步骤内的失败;它们替代不了跨步骤的顺序正确性。
- 顺序正确性:
publishAll必须在clear之前执行 - 一旦顺序错,横向兜底全部作废
- CR 时看到"我们有 Outbox 呢"就放心?错。要看顺序是否正确。
6. 修复方案
6.1 决策原则:Repository 只做数据翻译,事件生命周期归 Service
这条原则回归到 DDD 的单一职责:
- Repository:聚合根 ↔ 数据库的技术翻译。不感知事件、不感知业务、不感知事务边界。
- ApplicationService / DomainService:Use Case 编排。持有事务、协调持久化、发布事件、清理事件。
按这个原则,Repository 里出现 aggregate.clearDomainEvents() 就是跨层越权,无论"看起来多贴心"。
6.2 惯用三行式
Service 层写法固定为:
@Transactional(rollbackFor = Exception.class)
public XxxResult doSomething(XxxCommand command) {
XxxAggregate aggregate = ...; // 拉聚合 or new 聚合
aggregate.doBusiness(...); // 调聚合根业务方法,事件被 add 进 aggregate.events
repo.save(aggregate); // ① 持久化业务表
domainEventPublisher.publishAll(aggregate.getDomainEvents()); // ② 事件入 Outbox(同事务)
aggregate.clearDomainEvents(); // ③ 清空内存事件
return XxxResult.of(aggregate);
}顺序不能乱:
- ① 在 ② 前:因为业务表和 outbox 表在同一事务里,谁先无所谓,但业务概念上应先落业务再发事件
- ② 在 ③ 前:
publishAll要拿到真实事件列表;② 之后事件已在 outbox 表落地,③ 只是清内存副本 - ③ 之所以还需要:防止同一个 aggregate 实例被后续代码再次
getDomainEvents重复发;虽然一般 Service 方法返回后 aggregate 就被 GC,但显式 clear 是防御性习惯
6.3 治理动作(本次落地)
| # | 动作 | 状态 |
|---|---|---|
| 1 | 删除 5 个 Repository (Content/Chat/Security/Matching/Activity) 里的 aggregate.clearDomainEvents() 共 10 处 | ✅ |
| 2 | AbstractJpaAggregateRepository Javadoc 明确"严禁在 Repository 内做领域事件生命周期管理" | ✅ |
| 3 | ADR-011 追加「事件生命周期不属于 Repository」章节 | ✅ |
| 4 | .cursor/rules/ddd-repository-update.mdc 追加硬约束 + 自检清单第 6 项 | ✅ |
| 5 | 本学习笔记归档 | ✅ 你在读的这份 |
6.4 未来演进:publishAndClear 语义化封装(暂缓)
一个自然的下一步:把"publish + clear"两步封装成 IDomainEventPublisher.publishAndClear(aggregate):
default void publishAndClear(AggregateRoot aggregate) {
publishAll(aggregate.getDomainEvents());
aggregate.clearDomainEvents();
}好处:
- Service 层只写一行,杜绝"分开写导致遗漏 clear"
- 明确"发布"和"清理"是耦合的原子步骤
暂缓原因:
- 需要抽
AggregateRoot基类或接口约束getDomainEvents/clearDomainEvents,目前 5 个聚合根都是"结构相似但没共享基类",改造范围偏大 - 本次事故的核心已经通过"删 Repository 抢跑 + 规则硬约束"消除
- 若未来出现"忘记 clear"型 bug,再作为独立 ADR 引入
7. DDD 收获(教学篇)
这个 bug 从 DDD 学习角度带来 4 个可以沉淀成信条的教训:
7.1 层的职责边界不是"哪层能不能做",而是"哪层最有资格做"
Repository 也能调用 clearDomainEvents() —— Java 语法允许。但它没有资格 ——
- Repository 不知道 Service 有没有 publishAll 过(Repository 只是被调用者)
- Repository 不知道当前事务边界在哪(Repository 不该关心事务)
- Repository 不知道后续还会不会有第二次 save(Repository 只处理当前这一次调用)
只有 Service 层拥有"完整 Use Case 视图",能决定 clear 的正确时机。
沉淀原则:判断跨层职责归属时,问"谁看到的信息最全"。
7.2 "贴心保底"是最危险的耦合
新人(包括当年的老维护者)写代码时容易出现一种模式:
"反正这里以后一定要 X,我在这里帮你做了。"
这种"提前帮你做"看起来是好意,实际上:
- 让上游误以为不用做:上游 Service 作者以为 Repository 已经清了,就不做 clear —— 结果一旦上游必须自己 clear(比如加了 publishAll),两边都以为"另一方会做",结果两边都不做(或时序错乱)。
- 让职责边界模糊:新人 CR 时看到 Repository 里的 clear,不确定"这是应该的还是不应该的" —— 心智成本翻倍。
- 让重构成为炸药引信:如"引入 Outbox"这样的重构,隐藏的时序假设不被打破就没事,一被打破立刻爆。
沉淀原则:任何"我在这里帮你做 X" 的贴心保底,必须问自己"如果调用方也做了 X 呢?两次调用的时序是什么?如果调用方期望自己做但我提前做了呢?" —— 想不清就不要做。
7.3 事件驱动架构必须"端到端"看
传统 CRUD 架构里,你看单个方法就能判断对错。事件驱动架构里不行,你必须顺着事件走完整条链才能判断对错:
[产生] aggregate.publishPost() {addDomainEvent}
↓
[收集] aggregate.getDomainEvents() → [List<DomainEvent>]
↓
[发布] domainEventPublisher.publishAll(events)
↓
[入库] domain_event_record INSERT (PENDING)
↓
[试发] Spring EventBus.publish(evt)
↓
[消费] @EventListener onXxxEvent(...) { doSideEffect }
↓
[标记] domain_event_record UPDATE (PUBLISHED / FAILED)
↓
[补偿] SagaRecoveryTask 定时扫 FAILED 重发本次事故的隐蔽性来自"上游看着没问题、下游看着没触发",两端各自都合理,问题在中间某一步的时序 —— 只有端到端追一遍才能发现。
沉淀原则:CR 涉及事件的代码时,把整条链画出来,标注"事件此时在哪个容器里"(内存列表?outbox 表?消息队列?消费者?)。
7.4 Outbox 模式的"完整生效前提"
Outbox 模式书上讲得非常美好,但落地时必须捍卫一个前提:事件在业务事务内被写入 Outbox 表。
如果这个前提被任何原因破坏(本次是 Repository 抢跑 clear;其他项目里常见的破坏方式还有:先 commit 事务再 publish、把 publish 放到独立事务里、把事件生命周期方法暴露给非 Service 层等),Outbox 模式表面上还在运转(有 outbox 表、有定时任务、有异步 listener),但实际上已经沦为摆设 —— 因为它保护的东西已经不在保护范围内了。
沉淀原则:任何时候在项目里看到 Outbox 模式,第一件事是问:事件是否 100% 在业务事务内进入了 outbox 表? 如果不能一句话说"是的,因为 …",就说明这个 Outbox 是花架子。
8. 防守清单:如何避免再犯
8.1 硬约束(工具兜底)
- ✅ Cursor Rule 已加:编辑
**/adapter/repository/**/*.java时自动加载ddd-repository-update.mdc,其中明确禁止aggregate.clearDomainEvents() - ✅ AbstractJpaAggregateRepository Javadoc 明确写出"严禁"
- ⏳ 可选加强:加个静态检查(
grep -rn "clearDomainEvents" tour-mate-platform-infrastructure/**/adapter/repository/*.java命中即 CI fail)
8.2 Code Review 清单
看 Repository:
- [ ]
save/update方法体是否只有 "日志 + doSave/doUpdate + 日志"? - [ ] 是否没有
aggregate.clearDomainEvents()? - [ ] 是否没有
domainEventPublisher.publishXxx(...)?(Repository 更不该发事件) - [ ] 是否没有任何跨聚合的业务逻辑?
看 ApplicationService:
- [ ] 涉及聚合根修改的方法是否
@Transactional? - [ ] 三行式顺序是否正确:
repo.save/update → publishAll → clearDomainEvents? - [ ]
publishAll的入参是不是aggregate.getDomainEvents()?(不要传空列表、不要传 null)
看聚合根:
- [ ]
addDomainEvent是否用过去时命名(XxxHappenedEvent)? - [ ] 是否没有在聚合根内部调用
publishXxx(聚合根只 add,Service 负责 publish)?
8.3 事故重现的单元测试(推荐补上)
给关键聚合根加一个针对 Repository 抢跑的回归测试:
@Test
@DisplayName("ContentAggregateRepository.save 后聚合根事件不应被清空(防抢跑)")
void save_should_not_clear_domain_events() {
ContentAggregate aggregate = createNewAggregate();
aggregate.publishPost();
assertThat(aggregate.getDomainEvents()).hasSize(1);
contentAggregateRepository.save(aggregate);
assertThat(aggregate.getDomainEvents())
.as("Repository 不应管理事件生命周期,事件必须保留给上游 ApplicationService")
.hasSize(1);
}(本次修复未一并加测试,主因是项目当前测试基础设施尚未成型;未来引入测试框架后应补上。)
9. 附录
9.1 涉及文件清单
改动的 5 个 Repository(删除 aggregate.clearDomainEvents() 共 10 处):
tour-mate-platform-infrastructure/src/main/java/com/alisunxin/api/infrastructure/adapter/repository/ContentAggregateRepository.javatour-mate-platform-infrastructure/src/main/java/com/alisunxin/api/infrastructure/adapter/repository/ChatAggregateRepository.javatour-mate-platform-infrastructure/src/main/java/com/alisunxin/api/infrastructure/adapter/repository/SecurityAggregateRepository.javatour-mate-platform-infrastructure/src/main/java/com/alisunxin/api/infrastructure/adapter/repository/MatchingAggregateRepository.javatour-mate-platform-infrastructure/src/main/java/com/alisunxin/api/infrastructure/adapter/repository/ActivityAggregateRepository.java
改动的规范文档:
docs/decisions/ADR-011-聚合根仓储update统一模板.md(追加「事件生命周期不属于 Repository」章节).cursor/rules/ddd-repository-update.mdc(追加硬约束 + 第 6 条自检项)tour-mate-platform-infrastructure/.../base/AbstractJpaAggregateRepository.java(Javadoc 明确职责)
未改动但关键的参考(要读懂本文必看):
tour-mate-platform-infrastructure/src/main/java/com/alisunxin/api/infrastructure/adapter/event/DomainEventPublisherAdapter.java— Outbox 实现tour-mate-platform-domain/src/main/java/com/alisunxin/api/domain/content/service/PostApplicationService.java— 三行式惯例的正例tour-mate-platform-domain/src/main/java/com/alisunxin/api/domain/activity/service/ActivityTransactionService.java— Saga + 三行式的正例tour-mate-platform-domain/src/main/java/com/alisunxin/api/domain/shared/event/package-info.java— 事件框架总览
9.2 相关 ADR 与规则
- ADR-011
.cursor/rules/ddd-repository-update.mdc.cursor/rules/saga-event.mdc.cursor/rules/ddd-core.mdc
9.3 术语速查表
| 术语 | 一句话解释 |
|---|---|
| 聚合根 (Aggregate Root) | 一群"必须一起改"的对象里对外暴露的那个根 |
| 领域事件 (Domain Event) | 聚合根业务方法产生的"过去时事实"通知 |
| Repository | 聚合根 ↔ 数据库的翻译层,不该有业务逻辑 |
| ApplicationService | Use Case 编排层,持有事务、协调持久化 + 事件 |
| Transactional Outbox | 把"事件发布"变成"outbox 表 INSERT",跟业务事务绑定 |
| Saga | 跨聚合的分布式事务编排(含补偿) |
@TransactionalEventListener | Spring 的事件监听器,可选择在事务提交后(AFTER_COMMIT)执行 |
| 三行式 | 本文提出的惯用写法:save → publishAll → clear |
这份复盘的目的:3 个月后有新人(或 3 个月后忘了这件事的我自己)翻到 ContentAggregateRepository.save 觉得"缺一个 clearDomainEvents 是不是有 bug",能顺着注释找到 ADR-011、再找到这份复盘,30 分钟内理解"为什么必须缺"。