关于 event-sourcing
面向有意采用事件溯源的团队,说明何时适用,并指导事件流的建模、持久化、重放与演进,同时保持领域逻辑和基础设施分离。
主要功能
- 01事件溯源适用时机指南
- 02事件与流的建模原则
- 03决策器与状态重建模式
- 04事件存储端口与并发控制
- 05投影、版本管理与测试指南
适用场景
- 01评估在限界上下文中采用事件溯源
- 02设计事件流与命令处理流程
- 03构建并重建读取模型
- 04规划事件模式演进与生产保障
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ citypaul/ .dotfiles --skill event-sourcing安装后在 Claude Code 中输入/event-sourcing确认已加载。
包含的文件
除 SKILL.md 外,安装时一起下载的参考资料和脚本。
resources/decider-and-rehydration.mdevent-store.mdevent-versioning.mdmodelling-events.mdproduction-concerns.mdprojections-and-read-models.mdreferences.mdtesting-event-sourced-systems.mdwhen-to-use-event-sourcing.md详细介绍
事件溯源
事件溯源将应用状态的每次变更,都存储为不可变、只追加的过去时领域事件序列。当前状态绝不会直接存储,而是通过对事件进行左折叠得到:state = events.reduce(evolve, initialState)。事件日志是事实来源;其他所有表示(聚合状态、读模型、搜索索引)都是派生且可丢弃的投影,可通过重放事件重新构建。
此技能仅适用于有意采用事件溯源的项目,或项目中特定的有界上下文。**它位于复杂度阶梯的顶端,并非默认选择。**请先阅读“何时使用事件溯源”;大多数功能采用显式返回值、审计表或 outbox 模式即可。
此技能基于另外三个技能构建——请一并加载:
domain-driven-design— Decider(decide/evolve/initialState)定义于其resources/domain-events.md中。事件溯源会持久化并重放同一个 decider。此技能假定你已阅读该文档。hexagonal-architecture— 事件存储是带有适配器的被驱动端口;领域层保持纯粹。读取采用 CQRS-lite 拆分(resources/cqrs-lite.md)。typescript-strict— 事件在离开存储和返回存储的过程中会跨越信任边界,因此采用架构优先设计,并使用品牌 ID。
深入学习资源位于 resources/ 中。按需加载:
| 资源 | 在以下情况下加载…… |
|---|---|
when-to-use-event-sourcing.md | 决定是否采用事件溯源;与 CQRS、事件驱动架构、流处理和审计日志进行比较 |
modelling-events.md | 发现并命名事件(EventStorming)、事件粒度,以及事件中应包含哪些数据 |
decider-and-rehydration.md | 编写 decide/evolve 配对、重新水合、命令处理程序循环和 decider 组合 |
event-store.md | 设计 EventStore 端口、Postgres 架构、乐观并发、事件信封,以及 TS/Node 工具生态 |
projections-and-read-models.md | 构建读模型、内联与异步投影、重建、检查点和最终一致性 |
event-versioning.md | 演进事件架构——向上转换、宽容读取器、弱架构、流复制转换 |
testing-event-sourced-systems.md | 将 decider、投影和向上转换器作为行为进行测试,可直接测试,也可通过现有测试 DSL 测试 |
production-concerns.md | 快照、GDPR/加密销毁、幂等交付、可运维性和反模式目录 |
references.md | 查阅本指南背后的理由和一手来源 |
有关此技能附带的权威来源,请参阅 resources/references.md。
何时使用事件溯源
只有当变更历史本身就是领域的一部分时,事件溯源才值得承担其相当大的复杂性。它不是一种存储优化,而是一项建模决策。
在以下情况下使用事件溯源:
- 审计跟踪是首要需求——例如金融、医疗保健、合规领域;“某个日期的状态是什么样的,为什么?”是一个实际问题。
- 你需要时态查询或时间回溯——重建过去的状态、重放事件以复现 Bug,或回答数据写入时没人想到的问题。
- 领域确实是事件驱动的——业务使用事件来描述(“支付已扣款”“保单已失效”),并且多个下游模型会对此作出响应。
- 你需要基于相同事实构建多个独立演进的读取模型,并希望通过重放历史记录来追溯性地添加新模型。
在以下情况下不要使用事件溯源:
- 领域属于 CRUD——只是操作数据的表单,没有有意义的历史记录。存储当前状态即可。
- 你只需要审计日志——在当前状态表旁边添加一张仅追加的历史表。成本低得多。
- 你只需要跨聚合的副作用——使用领域事件和事务发件箱模式(参见
domain-driven-design/resources/domain-events.md)。这是事件驱动,不是事件溯源。 - 团队没有相关经验,且领域并不要求使用它。常见的失败结果是构建了一半的事件存储,却没有版本控制策略,最终变得无法更改。
复杂度阶梯(来自 DDD 的 domain-events.md,并有所扩展):显式返回值 → 进程内领域事件 → 发件箱模式 → 事件溯源。一次只攀升一级,且只有在当前级别无法满足需求时才升级。事件溯源是最后一级;应审慎选择,通常只用于一个限界上下文,而不是整个系统。
**事件溯源不等于 CQRS。**两者彼此正交。CQRS 将写入模型与读取模型分离;事件溯源则选择事件作为写入模型的存储方式。你可以不使用事件溯源而采用 CQRS(即六边形架构技能中的轻量级 CQRS),也可以——但很少见——不采用 CQRS 而使用事件溯源。实际中,事件溯源几乎总会推动 CQRS,因为存储的事件并不适合查询,因此你需要将它们投影为读取模型。请参阅 when-to-use-event-sourcing.md,了解与事件驱动架构、流处理(Kafka)和审计日志的完整对比。
核心心智模型
四个理念构成了整个模式。
-
事件是真相的来源。 它们是不可变的事实,以过去时命名,用于记录发生了什么以及为什么。你永远不会对事件执行
UPDATE或DELETE,只会执行append。纠正错误意味着追加一个新的补偿事件,正如会计师绝不会抹去账本中的一条记录。 -
状态是对事件进行左折叠的结果。 不存在存储的“当前状态”。你可以按需通过对事件流应用
evolve函数来重建状态——这个重建过程称为重新水合或重放。Greg Young 的表述是:“当前状态是对先前行为进行左折叠的结果。”const rehydrate = (events: readonly AccountEvent[]): AccountState => events.reduce(evolve, initialState); -
事件流是一致性边界。 一个聚合实例恰好对应一个事件流(例如
account-4f3c…)。该聚合的所有不变量都在其事件流内得到强制执行,追加操作则基于事件流版本采用乐观并发控制。你绝不会依赖跨事件流事务来保证聚合不变量成立——事件流才是一致性边界,跨聚合工作则由流程管理器/Saga 处理(某些存储可以原子性地追加到多个事件流,但建模良好的聚合永远不需要这样做)。 -
读模型是可丢弃的派生结果。 由于状态是
fold(events),任何针对读取进行优化的视图都只是另一种折叠结果。你可以删除一个投影,并从第零个事件开始重建。这让“追溯性地新增报表”变得轻而易举。
术语(本技能始终使用以下术语):
| 术语 | 含义 |
|---|---|
| 事件 | 不可变的过去时事实:MoneyDeposited、OrderShipped。存储单位。 |
| 命令 | 请求尝试进行更改:DepositMoney。可能被拒绝。永不存储。 |
| 事件流 | 一个聚合实例的有序事件序列。一致性边界。 |
| 决策器 | 纯写入模型:decide(命令 + 状态 → 事件)和 evolve(状态 + 事件 → 状态)。 |
| 重新水合/重放 | 通过对事件流中的事件应用 evolve 进行左折叠来重建状态。 |
| 投影 | 将事件折叠为读模型(针对查询优化的视图)。 |
| 快照 | 为避免重放较长的事件流而存储的缓存折叠结果。它是一种优化,绝不是真相的来源。 |
| 事件存储 | 用于持久化事件流的追加式、有序、支持乐观并发控制的存储。一个被驱动端口。 |
| 预期版本 | 决定某条命令时所依据的事件流版本;用于在追加时检测并发写入。 |
决策器就是写入模型
对于事件溯源,你不需要编写新的领域逻辑——你只需持久化已有的 DDD 技能中的决策器。函数仍然是那三个,只是这项技能做了一处改进:decide 返回显式的 Decision(接受并返回事件,或拒绝并说明原因),而不是 DDD 示例中直接返回事件数组:
// domain/account/account.ts — pure, no infrastructure imports
type AccountState =
| { readonly status: 'unopened' }
| { readonly status: 'open'; readonly balance: Money };
type Money = { readonly minorUnits: number; readonly currency: Currency };
type AccountCommand =
| { readonly type: 'Open'; readonly currency: Currency }
| { readonly type: 'Deposit'; readonly amount: Money }
| { readonly type: 'Withdraw'; readonly amount: Money };
type AccountEvent =
| { readonly type: 'AccountOpened'; readonly currency: Currency }
| { readonly type: 'MoneyDeposited'; readonly amount: Money }
| { readonly type: 'MoneyWithdrawn'; readonly amount: Money };
const initialState: AccountState = { status: 'unopened' };
const isPositiveMoney = (money: Money): boolean =>
Number.isSafeInteger(money.minorUnits) && money.minorUnits > 0;
// decide: what SHOULD happen? Returns events (or a rejection). Enforces invariants.
const decide = (command: AccountCommand, state: AccountState): Decision<AccountEvent> => {
switch (command.type) {
case 'Open':
if (state.status === 'open') return reject('already-open');
return accept([{ type: 'AccountOpened', currency: command.currency }]);
case 'Deposit':
if (state.status !== 'open') return reject('not-open');
if (!isPositiveMoney(command.amount)) return reject('invalid-amount');
if (command.amount.currency !== state.balance.currency) return reject('currency-mismatch');
if (!Number.isSafeInteger(state.balance.minorUnits + command.amount.minorUnits)) {
return reject('balance-overflow');
}
return accept([{ type: 'MoneyDeposited', amount: command.amount }]);
case 'Withdraw':
if (state.status !== 'open') return reject('not-open');
if (!isPositiveMoney(command.amount)) return reject('invalid-amount');
if (command.amount.currency !== state.balance.currency) return reject('currency-mismatch');
if (command.amount.minorUnits > state.balance.minorUnits) return reject('insufficient-funds');
return accept([{ type: 'MoneyWithdrawn', amount: command.amount }]);
default: { const _: never = command; return _; }
}
};
const corruptHistory = (state: AccountState, event: AccountEvent): never => {
throw new Error(`Corrupt account stream: ${event.type} cannot follow ${state.status}`);
};
const addMinorUnits = (left: number, right: number): number => {
if (!Number.isSafeInteger(left) || !Number.isSafeInteger(right)) {
throw new Error('Corrupt account stream: unsafe minor-unit operand');
}
const minorUnits = left + right;
if (!Number.isSafeInteger(minorUnits) || minorUnits < 0) {
throw new Error('Corrupt account stream: invalid resulting balance');
}
return minorUnits;
};
const applyDelta = (balance: Money, deltaMinorUnits: number): Money =>
({ ...balance, minorUnits: addMinorUnits(balance.minorUnits, deltaMinorUnits) });
// evolve applies facts; an impossible known transition is corrupt history, not a no-op.
const evolve = (state: AccountState, event: AccountEvent): AccountState => {
switch (event.type) {
case 'AccountOpened': {
if (state.status !== 'unopened') return corruptHistory(state, event);
return { status: 'open', balance: { minorUnits: 0, currency: event.currency } };
}
case 'MoneyDeposited':
if (
state.status !== 'open' ||
!isPositiveMoney(event.amount) ||
event.amount.currency !== state.balance.currency
) return corruptHistory(state, event);
return { ...state, balance: applyDelta(state.balance, event.amount.minorUnits) };
case 'MoneyWithdrawn':
if (
state.status !== 'open' ||
!isPositiveMoney(event.amount) ||
event.amount.currency !== state.balance.currency ||
event.amount.minorUnits > state.balance.minorUnits
) return corruptHistory(state, event);
return { ...state, balance: applyDelta(state.balance, -event.amount.minorUnits) };
default: { const _: never = event; return _; }
}
};
金融事件使用整数最小货币单位和货币代码。命令边界会拒绝不安全的整数(NaN、无穷大、小数和溢出值);从主要货币单位的十进制金额转换时,必须先明确指定货币小数位数和舍入策略,再构建命令;绝不能持久化二进制浮点金额。
职责划分必须严格遵守(如果弄错,整个模型都会腐化):
decide包含所有业务规则。它读取当前状态、验证命令,然后要么接受(返回事件),要么拒绝(返回业务原因)。它没有副作用,也不接触存储。evolve不包含任何命令规则。它会穷尽应用每个有效的已存储事件。已知事件如果无法在当前状态下发生,说明历史记录已损坏,因此重放必须报告损坏错误,而不是悄悄保留状态。容错读取器和升级器可以让较旧的有效事件仍可读取,但不能让不可能的状态转换变得有效。
将 decide 的结果建模为显式的 Decision(接受并返回事件,或拒绝并说明原因),遵循了 DDD 技能的错误建模规则:预期的业务结果应使用结果类型表示,而不是异常。请参阅 decider-and-rehydration.md,了解 Decision、isTerminal 以及决策器的组合方式。
命令处理器循环
应用服务将纯决策器与有副作用的事件存储连接起来。这个循环是每次事件溯源写入的核心,而且每次都一样:
// use-case: load → decide → append (with optimistic concurrency)
const handleCommand = async (
store: EventStore<AccountEvent>,
streamId: StreamId,
command: AccountCommand,
): Promise<CommandResult> => {
// 1. LOAD the stream's events (and the version we read at)
const { events, version } = await store.readStream(streamId);
// 2. REHYDRATE current state by folding — pure
const state = events.reduce(evolve, initialState);
// 3. DECIDE — pure business logic
const decision = decide(command, state);
if (!decision.accepted) return { success: false, reason: decision.reason };
// 4. APPEND the new events, asserting the stream has not moved since we read it
const outcome = await store.appendToStream(streamId, decision.events, { expectedVersion: version });
if (outcome === 'version-conflict') return { success: false, reason: 'concurrent-modification' };
return { success: true, events: decision.events };
};
所有纯逻辑(evolve、decide)都能轻松测试,无需模拟对象。所有有副作用的操作都位于 EventStore 端口之后。追加时使用的预期版本可确保并发写入安全:如果另一个写入者在步骤 1 和步骤 4 之间向流追加了事件,追加操作就会失败,调用方会从步骤 1 开始重试(重新加载最新状态,再次决策)。此代码片段展示的是基本形式;生产环境中的实现会将步骤 1–4 包装在有界的重新加载/重新决策重试循环中(请参阅 decider-and-rehydration.md)。绝不能跳过预期版本——否则,两个并发提款都可能通过余额检查,导致账户透支。
事件存储端口
事件存储是一个被驱动端口(参见六边形架构技能)。应用命令处理器会使用它,因此应使用应用层语言在应用层定义接口,并在适配器中实现。领域事件类型和纯决策器应保留在领域层。最小契约很简单:
// port (application layer) — owned by the command handler that consumes it.
// Typed to one aggregate's event family, like a repository (the underlying
// adapter can hold many streams; it parses stored JSON into E on read).
interface EventStore<E> {
readonly readStream: (streamId: StreamId) => Promise<{ readonly events: readonly E[]; readonly version: number }>;
readonly appendToStream: (
streamId: StreamId,
events: readonly E[],
options: { readonly expectedVersion: number },
) => Promise<'ok' | 'version-conflict'>;
}
生产级存储还会添加订阅(用于异步投影)和全局事件顺序读取功能,但每个事件存储都必须提供:**只追加写入、流内排序,以及通过预期版本实现的乐观并发控制。**标准的 Postgres 实现会在追加事件的同一事务中,以原子方式比较并推进流头行;UNIQUE (stream_id, version) 仍是纵深防御约束。请参阅 event-store.md,了解完整架构、事件信封(包含关联 ID 和因果 ID),以及 Emmett、KurrentDB (EventStoreDB) 和 message-db 的适用场景。
事件以数据形式存储在信任边界之外,因此在传出时,会先通过架构进行验证(使用容错读取器),之后 evolve 才能看到这些事件。这就是事件溯源与 typescript-strict 的交汇之处:存储的 JSON 是不可信输入,读取时会被解析为带品牌的领域事件。版本管理也在此处处理——见下文。
作为数据的事件
事件是你编写的最具持久性的内容。表中的一行可以迁移;事件则是历史事实,只要系统还在运行,就会被重放。请谨慎设计(完整指南见 modelling-events.md):
- 使用过去时,以业务语言命名。
SubscriptionCancelled,而不是UpdateStatus。FundsWithdrawn,而不是RowUpdated。事件命名的是领域中已经发生的事情。 - 避免 CRUD 事件。
AccountCreated/AccountUpdated/AccountDeleted是披着事件溯源外衣的数据库变更日志——它记录的是数据发生了变化,而不是发生了什么或为什么。应对意图建模:AccountOpened、AddressCorrected、AccountClosed。 - 将事实放入事件,而不是放入日后需要查找的引用。 多年以后,事件也必须能独立解读,无需与可变表联接。记录事件发生当时为真的值(收取的价格、应用的费率),而不是指向已发生变化的行的外键。
- **为每个事件添加信封:**唯一 ID、类型、流 ID、版本、时间戳和元数据(用于追踪命令及其引发的事件的关联 ID 和因果 ID)。领域负载与此信封分开。
- 将事件建模为带判别字段的联合类型,使用
type作为判别字段并进行穷尽处理,就像命令和状态一样——使用 DDD 技能中的相同never守卫式switch。
投影与读模型(CQRS)
你几乎不会查询事件存储来回答用户的问题——每次读取时折叠一个流无法扩展,而跨聚合查询需要的数据,流会刻意分开保存。相反,应将事件信封投影到读模型中:投影只是另一次折叠,envelopes.reduce(applyToBalanceView, emptyBalanceView),其结果是一个符合查询需求的表。
// A projection is a fold from events → a read-optimised row
type BalanceView =
| { readonly status: 'unopened' }
| {
readonly status: 'open';
readonly accountId: StreamId;
readonly balanceMinorUnits: number;
readonly currency: Currency;
};
type AccountProjectionEnvelope = {
readonly streamId: StreamId;
readonly globalPosition: bigint;
readonly data: AccountEvent;
};
const emptyBalanceView: BalanceView = { status: 'unopened' };
const corruptBalanceProjection = (view: BalanceView, event: AccountEvent): never => {
throw new Error(`Corrupt balance projection: ${event.type} cannot follow ${view.status}`);
};
const applyToBalanceView = (
view: BalanceView,
envelope: AccountProjectionEnvelope,
): BalanceView => {
const event = envelope.data;
switch (event.type) {
case 'AccountOpened':
if (view.status !== 'unopened') return corruptBalanceProjection(view, event);
return {
status: 'open',
accountId: envelope.streamId,
currency: event.currency,
balanceMinorUnits: 0,
};
case 'MoneyDeposited':
if (
view.status !== 'open' ||
view.accountId !== envelope.streamId ||
!isPositiveMoney(event.amount) ||
event.amount.currency !== view.currency
) return corruptBalanceProjection(view, event);
return { ...view, balanceMinorUnits: addMinorUnits(view.balanceMinorUnits, event.amount.minorUnits) };
case 'MoneyWithdrawn':
if (
view.status !== 'open' ||
view.accountId !== envelope.streamId ||
!isPositiveMoney(event.amount) ||
event.amount.currency !== view.currency ||
event.amount.minorUnits > view.balanceMinorUnits
) return corruptBalanceProjection(view, event);
return { ...view, balanceMinorUnits: addMinorUnits(view.balanceMinorUnits, -event.amount.minorUnits) };
default: { const _: never = event; return _; }
}
};
创建事件是从 unopened 转换到 open 的唯一转换,而读模型 ID 来自经过验证的信封中的 streamId,不是重复的负载数据或调用方提供的种子值。消费者会在此折叠前,按事件 ID 或检查点跳过完全相同的重复投递;因此,之后被接纳的 AccountOpened、开户前发生的资金事件,或来自其他流的信封,都不可能是已知的历史记录,必须停止投影以便调查。宽容读取和升级转换也会在此类型化折叠前完成;它们保留的是兼容的旧事件,而不是不可能的状态转换。
- 内联(同步)投影会在追加事件的同一事务中更新——没有延迟,但写入吞吐量会受到投影工作的影响。异步投影会订阅事件流并单独更新——可扩展且能隔离故障,代价是最终一致性(读模型可能比写入延迟几毫秒)。
- **最终一致性是主要权衡。**刚刚发出命令的用户可能无法立即在异步读模型中看到相应变化。应针对这种情况设计 UI(从命令返回刚写入的状态、显示乐观 UI),而不是假装延迟不存在。
- 投影可以丢弃。跟踪检查点(已处理的最后一个事件位置);要重建时,将读模型和检查点重置为零,然后重放。这是在多年累积的历史记录上添加全新读模型,以及修复有缺陷投影的方式。
- 投影必须具备幂等性。投递至少会发生一次,因此多次应用同一事件不能重复计数。使用事件 ID或全局位置(在整个存储中唯一)作为键;只有当读模型行属于单个流时,单独的流
version才足够,因为不同流的版本会重复。
这是十六进制架构技能中 CQRS-lite 的写入/读取分离的完整形式:写入经由判定器和事件存储;读取经由投影后的读模型。详见 projections-and-read-models.md。
测试事件溯源系统
事件溯源是最容易测试的模式之一,因为写入模型是纯的:事件输入 → 状态 → 命令 → 事件输出,全都是普通数据。
事件溯源领域的文献几乎总会采用给定-当-则的结构——给定这些过去的事件,当处理这条命令时,则预期得到这些新事件——通常会使用流畅的 given(...).when(...).then(...) DSL 或 Gherkin 场景。这样做有充分的理由:这种结构就是决策器的代数。 过去的事件折叠为状态,应用一条命令,然后断言生成的事件。值得理解这种模式为何如此契合。
使用仓库中既有的测试风格来表达这种代数。直接的行为驱动测试(参见 testing 技能)会调用公共函数,断言观察到的输出,以业务行为为测试命名,并使用工厂构造数据。已有的给定-当-则辅助工具也可以表达相同的行为;不要仅仅因为事件溯源领域的文献采用这种写法,就引入新的 DSL。直接的写法无需增加任何机制,就能清晰对应:
- “给定过去的事件” → 使用工厂构造初始状态,或通过
evolve折叠事件工厂生成的事件。(过去的事件只是数据。) - “当一条命令” → 直接调用公共函数(
decide或命令处理程序)。 - “则预期得到事件” → 对返回的事件使用
expect(...)断言——这些事件就是decide可观察到的输出。
// ✅ Ordinary behaviour-driven test through the public decider API.
// The returned events are the observable behaviour of the public `decide` function.
const openAccount = (currency: Currency = 'GBP'): AccountEvent =>
({ type: 'AccountOpened', currency });
const money = (minorUnits: number, currency: Currency = 'GBP'): Money => ({ minorUnits, currency });
const deposited = (minorUnits: number): AccountEvent => ({ type: 'MoneyDeposited', amount: money(minorUnits) });
it('should reject a withdrawal that exceeds the balance', () => {
const state = [openAccount(), deposited(5_000)].reduce(evolve, initialState);
const decision = decide({ type: 'Withdraw', amount: money(10_000) }, state);
expect(decision).toEqual({ accepted: false, reason: 'insufficient-funds' });
});
it('should record a deposit as a MoneyDeposited event on an open account', () => {
const state = [openAccount()].reduce(evolve, initialState);
const decision = decide({ type: 'Deposit', amount: money(5_000) }, state);
expect(decision).toEqual({ accepted: true, events: [{ type: 'MoneyDeposited', amount: money(5_000) }] });
});
初始状态由工厂构造的事件折叠而来,“操作”是直接调用,断言则针对返回的数据——这就是通过公共 API 测试行为,符合 testing 技能的要求。无需事件总线或模拟对象。工厂可以让重复的历史记录更易读,但明确标注类型的一次性内联事件同样有效。缺少上下文类型的数组字面量可能会将其 type 判别属性扩大为 string;可通过 AccountEvent[] 注解或 satisfies readonly AccountEvent[] 避免这种情况。以同样的方式测试投影和升级器(它们也是折叠操作和纯函数):输入事件,断言读取模型或升级后的事件。完整模式——包括使用内存中的 EventStore 假对象测试命令处理程序,以及对折叠过程进行属性测试——见 testing-event-sourced-systems.md。
快照(简述)
快照是存储的折叠结果——{ version: 240, state: {…} }——因此可以从快照及其之后的事件重新水合,而无需从事件零开始。**快照是缓存,绝非事实来源:**删除所有快照后,系统从事件重建得到的状态必须完全相同。大多数流都足够短,根本不需要快照——只有当流增长到导致重放明显变慢时才考虑使用快照,而且绝不要提前使用。参见 production-concerns.md。
反模式
- 到处使用事件溯源。 将其应用于整个系统,而不是仅用于一两个历史属于领域本身的上下文。大多数上下文都是 CRUD;让它们保持 CRUD。
- CRUD 事件。 与表写入相对应的
Created/Updated/Deleted事件。它们只记录数据发生了变化,而不是发生了什么。应对意图建模。 - 没有版本控制策略。 发布 v1 事件时,没有任何演进计划。由于事件不可变且永久存在,你必然需要用新代码读取旧结构——从第一天起就决定好宽容读取器/事件升级策略(
event-versioning.md)。 - 在
evolve中重新决策。 将命令策略放进重放逻辑中。evolve应用有效事实,不进行业务拒绝;但如果已知事件处于不可能的状态,就必须将其报告为历史记录损坏,而不是静默地不执行任何操作。 - 可变事件。 通过编辑或删除过去的事件来“修复”错误。正确的修复方式是新增补偿事件。一旦编辑历史记录,重放就不再可信,也违背了这一模式的核心承诺。
- 将快照当作事实来源。 把快照视为权威数据,而不是可重建的缓存。如果无法删除所有快照并重新构建,就失去了事件日志的保障。
- 将事件存储用作集成总线。 让其他服务直接订阅内部领域事件,会使它们与写入模型的结构耦合。应改为发布经过设计和版本控制的集成事件(两者的区别见 DDD 的
domain-events.md)。 - 臃肿事件/上帝流。 事件携带巨大的数据块,或用一个巨大的流处理所有内容,而不是每个聚合一个流。这两种做法都会破坏一致性边界和重放成本模型。
- 忽略最终一致性。 构建假设异步读取模型会立即更新的 UI。设计时应考虑延迟。
- 跳过预期版本。 在没有乐观并发控制的情况下追加事件,导致并发命令悄然违反不变量。
检查清单
- 选择事件溯源是因为历史属于领域本身,而不是将其作为默认方案,并且仅限于特定的限界上下文
- 先排除了更低成本的选项(显式返回值、审计表、outbox)
- 事件使用过去时、采用业务命名并清楚表达意图——没有 CRUD 事件
- 事件包含独立完整的数据(记录值,而不是可变引用),并建模为可区分联合类型
- 每个事件都有信封(id、类型、流 id、版本、时间戳、关联/因果元数据)
-
decide包含所有业务规则,并返回带事件的接受结果或带原因的拒绝结果 -
evolve穷尽式地应用每个有效的已存储事件;已知但不可能的状态转换会报告为损坏,而不是静默地不执行任何操作 - 状态通过折叠
evolve重建;没有任何地方将当前状态作为事实来源存储 - 追加操作使用乐观并发控制(预期版本);冲突通过重新加载并重试来处理
- 事件存储是一个被驱动端口;领域不导入任何基础设施
- 在读取时、信任边界处,使用架构对已存储事件进行验证(宽容读取器)
- 发布第一个事件之前就已制定版本控制策略(宽容读取器和/或升级器)
- 读取模型是投影(折叠),可从事件零开始通过检查点重建
- 投影具有幂等性,且 UI 会考虑最终一致性
- 快照(如果有)是可重建的缓存,绝不是权威数据
- 测试通过观察到的事件/状态/结果来验证公开行为;任何 DSL 都遵循仓库既有的测试风格,而不是额外引入仅供事件溯源使用的机制
- 如果事件包含个人数据,已确定 PII 策略(参见
production-concerns.md中的密码学擦除)