第26章:スナップショット実装(最小)📸🧪
この章のゴール🎯✨
- イベントが増えても、復元(Rehydrate)を短くできるようにする🔁💨
- **「最新スナップショット + その後のイベントだけ適用」**の流れを、TypeScriptで実装できるようにする🧩✅
- スナップショット有/無で「復元ステップ数(Apply回数)」が変わるのを体感する📉😳
まずイメージ🌈📸
スナップショットは、ざっくり言うと セーブデータ だよ🎮✨
- イベント列=プレイ履歴(最初から再生すると長い)📼
- スナップショット=途中のセーブ(ここから再開できる)💾
つまり復元はこうなる👇
- ふつう:イベント1件目から最後まで全部Apply…😵💫
- スナップあり:最新スナップを読み込み → 残りイベントだけApply😊
スナップショットの「最小要件」✅📦
最小で必要なのはこれだけ👇
- streamId(どの集約のスナップ?)🪪
- version(どこまでの履歴を反映した状態?)🔢
- state(その時点の状態)🧠
- createdAt(いつ作った?)🕒(任意だけど便利✨)
ポイント:イベントストアによっては「スナップショット専用機能」がなく、アプリ側で“普通のデータ/イベント”として扱う設計が一般的だよ📌(EventSourcingDB) (EventStoreDBでも「スナップショット用ストリームに保存して、最後のスナップから読み直す」みたいな考え方が紹介されてるよ)(Stack Overflow)
実装していこう🛠️✨(最小構成)
ここでは題材として「カート🛒」を使うよ(イベントは超ミニでOK)😊 やることは3つだけ👇
- Snapshot型とSnapshotStoreを作る📦
- Load時に「スナップ→残りイベント」を適用する🔁
- Save時に「たまにスナップを保存する」ルールを入れる📸
1) 型を用意する📦🧾
// snapshot.ts
export type Snapshot<TState> = {
streamId: string;
version: number; // このversionまでのイベントを反映したstate
state: TState;
createdAt: string; // ISO文字列
};
export interface SnapshotStore<TState> {
getLatest(streamId: string): Promise<Snapshot<TState> | null>;
save(snapshot: Snapshot<TState>): Promise<void>;
}
2) 最小SnapshotStore(インメモリ)📦🧠
「最新だけ保存」でOK🙆♀️(まずは最小!)
// inMemorySnapshotStore.ts
import { Snapshot, SnapshotStore } from "./snapshot";
export class InMemorySnapshotStore<TState> implements SnapshotStore<TState> {
private readonly latestByStream = new Map<string, Snapshot<TState>>();
async getLatest(streamId: string): Promise<Snapshot<TState> | null> {
return this.latestByStream.get(streamId) ?? null;
}
async save(snapshot: Snapshot<TState>): Promise<void> {
const current = this.latestByStream.get(snapshot.streamId);
// 古いスナップは上書きしない(versionが小さい)
if (current && snapshot.version <= current.version) return;
this.latestByStream.set(snapshot.streamId, snapshot);
}
}
3) 最小EventStore(インメモリ)📼✅
すでに作ってある前提でもOKだけど、この章だけでも動くように最小を置いとくね😊
// eventStore.ts
export type DomainEvent = {
type: string;
data: unknown;
meta?: { occurredAt?: string };
};
export type StoredEvent = DomainEvent & {
streamId: string;
version: number;
};
export class ConcurrencyError extends Error {}
export interface EventStore {
append(streamId: string, expectedVersion: number, events: DomainEvent[]): Promise<StoredEvent[]>;
readStream(streamId: string, fromVersion?: number): Promise<StoredEvent[]>;
getCurrentVersion(streamId: string): Promise<number>;
}
export class InMemoryEventStore implements EventStore {
private readonly streams = new Map<string, StoredEvent[]>();
async getCurrentVersion(streamId: string): Promise<number> {
const list = this.streams.get(streamId) ?? [];
return list.length === 0 ? 0 : list[list.length - 1].version;
}
async append(streamId: string, expectedVersion: number, events: DomainEvent[]): Promise<StoredEvent[]> {
const list = this.streams.get(streamId) ?? [];
const currentVersion = list.length === 0 ? 0 : list[list.length - 1].version;
if (currentVersion !== expectedVersion) {
throw new ConcurrencyError(`Expected ${expectedVersion}, but was ${currentVersion}`);
}
const now = new Date().toISOString();
const stored = events.map((e, i) => ({
...e,
streamId,
version: currentVersion + i + 1,
meta: { occurredAt: e.meta?.occurredAt ?? now },
}));
this.streams.set(streamId, [...list, ...stored]);
return stored;
}
async readStream(streamId: string, fromVersion: number = 1): Promise<StoredEvent[]> {
const list = this.streams.get(streamId) ?? [];
return list.filter(e => e.version >= fromVersion);
}
}
4) 集約(カート)を「スナップ対応」にする🛒📸
ここで大事なのは👇
- スナップに入れるのは state(状態)だけ(イベントは入れない)
- 復元時は スナップ state を初期値にして、残りイベントをApply
「Apply回数」を数えて差が見えるようにするよ😆📊
// cart.ts
export type CartState = {
cartId: string;
items: Record<string, number>; // productId -> qty
checkedOut: boolean;
};
export type CartEvent =
| { type: "CartCreated"; data: { cartId: string } }
| { type: "ItemAdded"; data: { productId: string; quantity: number } }
| { type: "CheckedOut"; data: {} };
export class DomainError extends Error {}
export class Cart {
private state: CartState;
public appliedCount = 0; // 何回Applyしたか(体感用)📏
private constructor(state: CartState) {
this.state = state;
}
static newEmpty(cartId: string): Cart {
return new Cart({ cartId, items: {}, checkedOut: false });
}
static fromSnapshot(state: CartState): Cart {
// ここは「そのまま信じて復元」する最小形✨
// 実務ではバリデーションやスキーマ移行を考えることもあるよ🧯
return new Cart(structuredCloneSafe(state));
}
toSnapshotState(): CartState {
return structuredCloneSafe(this.state);
}
// ---- Decide(コマンド→イベント)📮 ----
decideCreate(): CartEvent[] {
// すでに作成済みなら作れない…みたいなルールも本当は欲しいけど最小で省略🙆♀️
return [{ type: "CartCreated", data: { cartId: this.state.cartId } }];
}
decideAddItem(productId: string, quantity: number): CartEvent[] {
if (this.state.checkedOut) throw new DomainError("チェックアウト後は変更できません🥲");
if (quantity <= 0) throw new DomainError("数量は1以上だよ🙂");
return [{ type: "ItemAdded", data: { productId, quantity } }];
}
decideCheckout(): CartEvent[] {
if (this.state.checkedOut) throw new DomainError("もうチェックアウト済みだよ🙂");
const totalQty = Object.values(this.state.items).reduce((a, b) => a + b, 0);
if (totalQty === 0) throw new DomainError("空のカートはチェックアウトできません🛒💦");
return [{ type: "CheckedOut", data: {} }];
}
// ---- Apply(イベント→状態)🔁 ----
apply(event: CartEvent): void {
this.appliedCount++;
switch (event.type) {
case "CartCreated":
// cartIdは既に入ってる想定(最小)
return;
case "ItemAdded": {
const { productId, quantity } = event.data;
const current = this.state.items[productId] ?? 0;
this.state.items[productId] = current + quantity;
return;
}
case "CheckedOut":
this.state.checkedOut = true;
return;
default: {
const _exhaustive: never = event;
return _exhaustive;
}
}
}
getState(): CartState {
return structuredCloneSafe(this.state);
}
}
function structuredCloneSafe<T>(v: T): T {
// JSONで十分な最小実装(DateやMapが入るなら別対応)🧊
return JSON.parse(JSON.stringify(v)) as T;
}
5) ここが本題:Loadを「スナップ→残りイベント」にする🔁📸
流れはこれ👇
- 最新スナップを取得📸
- スナップがあれば、そのstateからCartを作る🧠
- スナップのversion+1からイベントを読み、Applyする📼➡️🔁
// cartRepository.ts
import { EventStore, StoredEvent } from "./eventStore";
import { SnapshotStore, Snapshot } from "./snapshot";
import { Cart, CartEvent, CartState } from "./cart";
export class CartRepository {
constructor(
private readonly eventStore: EventStore,
private readonly snapshotStore: SnapshotStore<CartState>,
) {}
// ✅ スナップ対応Load
async load(cartId: string): Promise<{
cart: Cart;
version: number;
snapshotVersion: number;
}> {
const currentVersion = await this.eventStore.getCurrentVersion(cartId);
const snap = await this.snapshotStore.getLatest(cartId);
// 変なスナップ(未来version)を踏まない保険🧯
const safeSnap = snap && snap.version <= currentVersion ? snap : null;
const cart = safeSnap ? Cart.fromSnapshot(safeSnap.state) : Cart.newEmpty(cartId);
const fromVersion = safeSnap ? safeSnap.version + 1 : 1;
const stored = await this.eventStore.readStream(cartId, fromVersion);
for (const e of stored) cart.apply(toCartEvent(e));
const finalVersion = stored.length > 0 ? stored[stored.length - 1].version : (safeSnap?.version ?? 0);
return {
cart,
version: finalVersion,
snapshotVersion: safeSnap?.version ?? 0,
};
}
// ✅ 保存(イベントをAppend)+「たまにスナップ保存」📸
async appendAndMaybeSnapshot(args: {
cartId: string;
expectedVersion: number;
newEvents: CartEvent[];
cartAfterApply: Cart; // Append後の状態(もうApply済み想定)
lastSnapshotVersion: number; // load時に分かったやつ
snapshotEvery: number; // 例:20
}): Promise<number> {
const stored = await this.eventStore.append(args.cartId, args.expectedVersion, args.newEvents);
const newVersion = stored[stored.length - 1]?.version ?? args.expectedVersion;
if (shouldTakeSnapshot(newVersion, args.lastSnapshotVersion, args.snapshotEvery)) {
const snapshot: Snapshot<CartState> = {
streamId: args.cartId,
version: newVersion,
state: args.cartAfterApply.toSnapshotState(),
createdAt: new Date().toISOString(),
};
await this.snapshotStore.save(snapshot);
}
return newVersion;
}
}
function shouldTakeSnapshot(newVersion: number, lastSnapshotVersion: number, every: number): boolean {
if (every <= 0) return false;
return newVersion - lastSnapshotVersion >= every;
}
function toCartEvent(e: StoredEvent): CartEvent {
// 最小:型の安全はここでは軽め(実務はevent typeごとに厳密に)🧷
return { type: e.type as CartEvent["type"], data: e.data as any };
}
6) ミニ演習:スナップ有/無で復元ステップ差を確認🔁📏✨

やること📝
- カートにアイテム追加イベントをいっぱい積む(例:100回)🛒
- スナップなし復元:Applyが100回近く走る😵
- スナップあり復元:Applyがほぼ0回(最新スナップがあるから)😊
// demo.ts
import { InMemoryEventStore } from "./eventStore";
import { InMemorySnapshotStore } from "./inMemorySnapshotStore";
import { CartRepository } from "./cartRepository";
import { Cart } from "./cart";
async function main() {
const eventStore = new InMemoryEventStore();
const snapshotStore = new InMemorySnapshotStore<any>();
const repo = new CartRepository(eventStore, snapshotStore);
const cartId = "cart-1";
const snapshotEvery = 20;
// まず作成
{
const loaded = await repo.load(cartId);
const cart = loaded.cart;
const evts = cart.decideCreate();
for (const e of evts) cart.apply(e);
await repo.appendAndMaybeSnapshot({
cartId,
expectedVersion: loaded.version,
newEvents: evts,
cartAfterApply: cart,
lastSnapshotVersion: loaded.snapshotVersion,
snapshotEvery,
});
}
// 100回追加(スナップも時々作られる)
for (let i = 0; i < 100; i++) {
const loaded = await repo.load(cartId);
const cart = loaded.cart;
const evts = cart.decideAddItem("apple", 1);
for (const e of evts) cart.apply(e);
await repo.appendAndMaybeSnapshot({
cartId,
expectedVersion: loaded.version,
newEvents: evts,
cartAfterApply: cart,
lastSnapshotVersion: loaded.snapshotVersion,
snapshotEvery,
});
}
// ✅ スナップありロード(Apply回数が少ないはず)
const withSnap = await repo.load(cartId);
console.log("with snapshot appliedCount =", withSnap.cart.appliedCount);
// ✅ スナップなしロードを“手動”で再現(最初からApply)
const noSnapCart = Cart.newEmpty(cartId);
const all = await eventStore.readStream(cartId, 1);
for (const e of all) noSnapCart.apply({ type: e.type as any, data: e.data as any });
console.log("without snapshot appliedCount =", noSnapCart.appliedCount);
}
main().catch(console.error);
期待する雰囲気👇😊
- with snapshot appliedCount ≒ 0(または少し)📉
- without snapshot appliedCount ≒ 101(作成+追加100)📈
7) テスト(Given-When-Then)🧪🌸
ここでは「状態が一致する」と「Apply回数が減る」をチェックするよ✅
// snapshot.test.ts
import test from "node:test";
import assert from "node:assert/strict";
import { InMemoryEventStore } from "./eventStore";
import { InMemorySnapshotStore } from "./inMemorySnapshotStore";
import { CartRepository } from "./cartRepository";
test("Given many events, When load with snapshot, Then state matches and apply count is smaller", async () => {
const eventStore = new InMemoryEventStore();
const snapshotStore = new InMemorySnapshotStore<any>();
const repo = new CartRepository(eventStore, snapshotStore);
const cartId = "cart-test";
const snapshotEvery = 10;
// Given: create
{
const loaded = await repo.load(cartId);
const cart = loaded.cart;
const evts = cart.decideCreate();
evts.forEach(e => cart.apply(e));
await repo.appendAndMaybeSnapshot({
cartId,
expectedVersion: loaded.version,
newEvents: evts,
cartAfterApply: cart,
lastSnapshotVersion: loaded.snapshotVersion,
snapshotEvery,
});
}
// Given: add 50 times (snapshots should exist)
for (let i = 0; i < 50; i++) {
const loaded = await repo.load(cartId);
const cart = loaded.cart;
const evts = cart.decideAddItem("apple", 1);
evts.forEach(e => cart.apply(e));
await repo.appendAndMaybeSnapshot({
cartId,
expectedVersion: loaded.version,
newEvents: evts,
cartAfterApply: cart,
lastSnapshotVersion: loaded.snapshotVersion,
snapshotEvery,
});
}
// When: load with snapshot
const withSnap = await repo.load(cartId);
// Then: apple qty is 50
assert.equal(withSnap.cart.getState().items["apple"], 50);
// Then: apply count should be small (latest snapshot should cover most history)
assert.ok(withSnap.cart.appliedCount < 10, `appliedCount was ${withSnap.cart.appliedCount}`);
});
8) よくある落とし穴💣😵💫(最小でもここは注意!)
- スナップのversionがズレる:versionが「どこまでApply済みか」そのものだよ🔢⚠️
- 未来スナップを踏む:イベントより先のversionのスナップは無効🧯(実装でガード入れたよ)
- stateに“派生値”を入れすぎ:合計金額みたいなのは、あとでズレやすい💸😇
- スキーマ変更で古いスナップが壊れる:壊れたら捨ててリプレイで再生成できるのが強み🔁✨
- 本当はトランザクション問題がある:イベント保存とスナップ保存が別タイミングだとズレる可能性📌(ここは“最小”なので割り切り!)
9) AI活用(Copilot/Codex向け)🤖✨
そのまま貼って使える系だよ📎💕
実装のたたき台を作らせるプロンプト🧱
- 「Snapshot型(streamId/version/state/createdAt)と InMemorySnapshotStore を作って。古いversionは上書きしないで」
- 「load時に snapshot があれば state から復元して、snapshot.version+1 からイベントを読んでApplyするRepositoryにして」
レビュー観点を出させるプロンプト🔍
- 「スナップのversion定義が正しい?(どこまでのイベントが反映済みか)」
- 「未来versionのスナップを踏まない?」「fromVersionが+1になってる?」
- 「スナップ保存の条件(every N events)が意図通り?」
テスト生成プロンプト🧪
- 「Given: 50回ItemAdded、When: snapshotEvery=10でload、Then: 状態一致 & appliedCountが小さい、のnode:testを書いて」
参考(2026-02-01 時点)📌
- TypeScriptのnpm上の最新安定版は 5.9.3 として案内されているよ🧾(npm)
- VS Codeは 2026年1月リリースとして v1.108 のリリースノートが公開されているよ🪟📝(Visual Studio Code)
- VS Code Insidersは 1.109 のノートが 2026-01-26 更新になってるよ🧪✨(Visual Studio Code)
- Node.js公式のEOLページでは、最新LTSや最新Currentの案内がまとめられているよ🟩(Node.js)
- スナップショットは「専用機能がないストアでは、アプリ側で通常データ/イベントとして扱う」説明があるよ📸(EventSourcingDB)