Skip to content

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. 事故场景(业务视角) ​

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.publishPostPostPublishedEventContentAggregateRepository.save发帖后所有下游子域断电
ChatAggregate.sendMessageMessageSentEventChatAggregateRepository.saveChat 目前没接入 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) ​

聚合根在执行业务行为的过程中,"顺便"产生的业务事实通知 —— 用过去时命名("帖子已发布"、"成员已加入")。

java
// 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 的入口,负责:

  1. 拉出聚合根(repo.findById(...))
  2. 调用聚合根的业务行为(aggregate.doBusiness())
  3. 持久化(repo.save(aggregate))
  4. 发布领域事件(domainEventPublisher.publishAll(aggregate.getDomainEvents()))
  5. 清理事件列表(aggregate.clearDomainEvents())
  6. 组装响应

它是唯一有权协调"业务 + 持久化 + 事件"的层。

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 又会重复发一遍。得记得清空。"

于是他做了个"贴心保底":

java
@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 的正确写法:

java
@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) 内部:

java
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 层写法固定为:

java
@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 处✅
2AbstractJpaAggregateRepository Javadoc 明确"严禁在 Repository 内做领域事件生命周期管理"✅
3ADR-011 追加「事件生命周期不属于 Repository」章节✅
4.cursor/rules/ddd-repository-update.mdc 追加硬约束 + 自检清单第 6 项✅
5本学习笔记归档✅ 你在读的这份

6.4 未来演进:publishAndClear 语义化封装(暂缓) ​

一个自然的下一步:把"publish + clear"两步封装成 IDomainEventPublisher.publishAndClear(aggregate):

java
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,我在这里帮你做了。"

这种"提前帮你做"看起来是好意,实际上:

  1. 让上游误以为不用做:上游 Service 作者以为 Repository 已经清了,就不做 clear —— 结果一旦上游必须自己 clear(比如加了 publishAll),两边都以为"另一方会做",结果两边都不做(或时序错乱)。
  2. 让职责边界模糊:新人 CR 时看到 Repository 里的 clear,不确定"这是应该的还是不应该的" —— 心智成本翻倍。
  3. 让重构成为炸药引信:如"引入 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 抢跑的回归测试:

java
@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.java
  • tour-mate-platform-infrastructure/src/main/java/com/alisunxin/api/infrastructure/adapter/repository/ChatAggregateRepository.java
  • tour-mate-platform-infrastructure/src/main/java/com/alisunxin/api/infrastructure/adapter/repository/SecurityAggregateRepository.java
  • tour-mate-platform-infrastructure/src/main/java/com/alisunxin/api/infrastructure/adapter/repository/MatchingAggregateRepository.java
  • tour-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聚合根 ↔ 数据库的翻译层,不该有业务逻辑
ApplicationServiceUse Case 编排层,持有事务、协调持久化 + 事件
Transactional Outbox把"事件发布"变成"outbox 表 INSERT",跟业务事务绑定
Saga跨聚合的分布式事务编排(含补偿)
@TransactionalEventListenerSpring 的事件监听器,可选择在事务提交后(AFTER_COMMIT)执行
三行式本文提出的惯用写法:save → publishAll → clear

这份复盘的目的:3 个月后有新人(或 3 个月后忘了这件事的我自己)翻到 ContentAggregateRepository.save 觉得"缺一个 clearDomainEvents 是不是有 bug",能顺着注释找到 ADR-011、再找到这份复盘,30 分钟内理解"为什么必须缺"。

Powered by VitePress