项目概述
本项目用事件溯源实现账户账本:账户是一串不可变事件,余额由重放推导而非权威存储,任何历史状态都可重建。相比可变行,它用更多存储与代码换来一个可辩护的答案——余额为何是现在这个数。
架构与关键决策
事件按聚合逐版本追加;加载聚合即按序重放事件折叠出状态,命令先在该状态上校验再产出新事件。追加携带命令观察到的版本,若已变化则拒绝,从而无需锁即可避免丢失更新。投影订阅新事件维护查询用的余额表,并在测试中与重放比对;因为投影可重建,其错误是重建问题而非数据丢失。事件不可变,纠错以补偿事件表达,这正是历史可信、存储仅追加的原因。
实施与测试
应用一串命令断言重放后的余额;并发发起两笔合计超额的取款断言恰好一笔成功;从头重建投影断言与增量更新一致;断言违反不变式的命令被拒且不产生事件。
限制与后续
- 无快照,重放成本随流长度增长。
- 投影单进程、可重建但未分片。
- 不支持跨聚合事务,转账依赖补偿事件。
代码示例
pnpm init
pnpm add pg express
pnpm add -D typescript tsx vitest
event-ledger/
├── src/aggregate.ts
├── src/append.ts
├── src/projection.ts
├── src/commands.ts
├── tests/ledger.test.ts
└── package.json
CREATE TABLE events (
stream_id text, version int, type text, payload jsonb,
PRIMARY KEY (stream_id, version));
let balance = 0;
for (const e of events) balance += e.type === 'deposited' ? e.amount : -e.amount;
INSERT INTO events(stream_id, version, type, payload)
VALUES ($1, $2, $3, $4) ON CONFLICT DO NOTHING RETURNING version;
await db.upsertBalance(accountId, balance);
assert.equal(projected.balance, replayBalance(events));
pnpm vitest run