mcpField
E
event-sourcing

event-sourcing

github.com/citypaul开发75976 次安装4 周前更新
代码仓库

关于 event-sourcing

面向有意采用事件溯源的团队,说明何时适用,并指导事件流的建模、持久化、重放与演进,同时保持领域逻辑和基础设施分离。

主要功能

  1. 01事件溯源适用时机指南
  2. 02事件与流的建模原则
  3. 03决策器与状态重建模式
  4. 04事件存储端口与并发控制
  5. 05投影、版本管理与测试指南

适用场景

  1. 01评估在限界上下文中采用事件溯源
  2. 02设计事件流与命令处理流程
  3. 03构建并重建读取模型
  4. 04规划事件模式演进与生产保障

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/citypaul/.dotfiles --skill event-sourcing

安装后在 Claude Code 中输入/event-sourcing确认已加载。

包含的文件

除 SKILL.md 外,安装时一起下载的参考资料和脚本。

resources/
decider-and-rehydration.md
event-store.md
event-versioning.md
modelling-events.md
production-concerns.md
projections-and-read-models.md
references.md
testing-event-sourced-systems.md
when-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)和审计日志的完整对比。


核心心智模型

四个理念构成了整个模式。

  1. 事件是真相的来源。 它们是不可变的事实,以过去时命名,用于记录发生了什么以及为什么。你永远不会对事件执行 UPDATE 或 DELETE,只会执行 append。纠正错误意味着追加一个新的补偿事件,正如会计师绝不会抹去账本中的一条记录。

  2. 状态是对事件进行左折叠的结果。 不存在存储的“当前状态”。你可以按需通过对事件流应用 evolve 函数来重建状态——这个重建过程称为重新水合或重放。Greg Young 的表述是:“当前状态是对先前行为进行左折叠的结果。”

    const rehydrate = (events: readonly AccountEvent[]): AccountState =>
      events.reduce(evolve, initialState);
    
  3. 事件流是一致性边界。 一个聚合实例恰好对应一个事件流(例如 account-4f3c…)。该聚合的所有不变量都在其事件流内得到强制执行,追加操作则基于事件流版本采用乐观并发控制。你绝不会依赖跨事件流事务来保证聚合不变量成立——事件流才是一致性边界,跨聚合工作则由流程管理器/Saga 处理(某些存储可以原子性地追加到多个事件流,但建模良好的聚合永远不需要这样做)。

  4. 读模型是可丢弃的派生结果。 由于状态是 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 中的密码学擦除)

评论

支持 Markdown