第13章:最小EventStore(インメモリ)を作る①:読み書きだけ📦✅
この章のゴール🎯✨
この章が終わると、こういう状態になります😊
- イベントを「追記(Append)」できる✍️📚
- ストリーム単位で「読み出し(ReadStream)」できる🔍📜
- 「イベントソーシングって、まず何が要るの?」の答えがコードで腹落ちする💡😺
1) まずイメージしよう:EventStoreって何?🧠📦
イベントソーシングでは、**状態(いまの結果)**じゃなくて、**出来事(過去の履歴)**を積んでいくよね😊 その「出来事の保管庫」が EventStore です📦✨

ポイントは超シンプル👇
- EventStoreは 追記専用(Append-only) 🧷✅
- 読むときは 「ある集約のイベント列」 をまとめて読む🔁📜
- 集約ごとにイベント列を分けるためのキーが streamId(ストリームID)🗝️
2) この章で作る“最小”の中身🧺✨
今回は「DB」なし! メモリ上にこういう箱を持つだけでOK😊
-
Map<streamId, StoredEvent[]>streamIdごとにイベント配列を持つ📚StoredEventは「イベント本体+ちょいメタ情報」入り🍱🏷️
まだ version(通し番号) は入れないよ! それは次の章(14章)で「順番が命🔥」としてちゃんとやる😊
3) 実装しよう:4ファイルで完成🎉🧩
ファイル構成(例)📁✨
src/domainEvent.tssrc/eventStore.tssrc/inMemoryEventStore.tssrc/demo.ts
3-1) src/domainEvent.ts(イベント型の土台)📮📜
// src/domainEvent.ts
/**
* DomainEvent = 「ドメインで起きた出来事」そのもの
* - type: 過去形のイベント名(例: "CartCreated")
* - data: 事実(payload)
* - meta: 補助情報(最小でOK)
*/
export type DomainEvent<
Type extends string = string,
Data extends Record<string, unknown> = Record<string, unknown>
> = Readonly<{
type: Type;
data: Data;
meta?: Record<string, unknown>;
}>;
/**
* StoredEvent = EventStoreに保存するときの包み(envelope)🍱
* - eventId / occurredAt は「いつ・どれ」って追えるようにするため
*/
export type StoredEvent<E extends DomainEvent = DomainEvent> = Readonly<{
eventId: string;
streamId: string;
occurredAt: string; // ISO文字列にしておくと扱いやすいよ🕒
event: E;
}>;
3-2) src/eventStore.ts(読み書きの約束=インターフェース)🧾✅
// src/eventStore.ts
import type { DomainEvent, StoredEvent } from "./domainEvent.js";
export interface EventStore<E extends DomainEvent = DomainEvent> {
/**
* Append = 追記する(保存というより「追加」)
*/
append(streamId: string, events: E[]): Promise<void>;
/**
* ReadStream = そのストリームのイベント列を読む
*/
readStream(streamId: string): Promise<StoredEvent<E>[]>;
}
3-3) src/inMemoryEventStore.ts(本体:Mapに積むだけ)📦🚀
// src/inMemoryEventStore.ts
import type { DomainEvent, StoredEvent } from "./domainEvent.js";
import type { EventStore } from "./eventStore.js";
export class InMemoryEventStore<E extends DomainEvent = DomainEvent>
implements EventStore<E>
{
private readonly streams = new Map<string, StoredEvent<E>[]>();
private nextId = 1;
async append(streamId: string, events: E[]): Promise<void> {
if (events.length === 0) return; // 何もないなら何もしない😺
const current = this.streams.get(streamId) ?? [];
const stored: StoredEvent<E>[] = events.map((event) => ({
eventId: this.newEventId(),
streamId,
occurredAt: new Date().toISOString(),
event,
}));
// 外から配列をいじられても壊れないように、毎回新しい配列にする✨
this.streams.set(streamId, [...current, ...stored]);
}
async readStream(streamId: string): Promise<StoredEvent<E>[]> {
const current = this.streams.get(streamId) ?? [];
// 返す側もコピーにして「読み出した人がpushして壊す」事故を防ぐ🛡️
return [...current];
}
private newEventId(): string {
// インメモリ学習用なので「連番で十分」だよ😊
const id = `evt_${String(this.nextId).padStart(6, "0")}`;
this.nextId += 1;
return id;
}
}
3-4) src/demo.ts(動作確認:Append → ReadStream 🔁)🧪✨
題材は「ショッピングカート」にするよ🛒💕
// src/demo.ts
import { InMemoryEventStore } from "./inMemoryEventStore.js";
import type { DomainEvent } from "./domainEvent.js";
// この章は「イベント列が読める」がゴールなので、イベント種類は少なくてOK😺
type CartEvent =
| DomainEvent<"CartCreated", { userId: string }>
| DomainEvent<"ItemAdded", { sku: string; qty: number }>
| DomainEvent<"ItemRemoved", { sku: string; qty: number }>;
const store = new InMemoryEventStore<CartEvent>();
const cartId = "cart-001";
// 追記(Append)✍️
await store.append(cartId, [
{ type: "CartCreated", data: { userId: "u-001" } },
{ type: "ItemAdded", data: { sku: "coffee", qty: 2 } },
{ type: "ItemAdded", data: { sku: "cookie", qty: 1 } },
]);
// 読み出し(ReadStream)🔍
const events = await store.readStream(cartId);
console.log("イベント件数:", events.length);
console.log(
"イベント順:",
events.map((x) => x.event.type).join(" -> ")
);
console.log("先頭のeventId:", events[0]?.eventId);
console.log("先頭のoccurredAt:", events[0]?.occurredAt);
✅ 実行できたら、だいたいこんな感じが出るはずだよ(例)😊
- イベント件数: 3
- イベント順: CartCreated -> ItemAdded -> ItemAdded
- eventId と occurredAt が付いてる📌🕒
4) ここが超大事:この章の“設計の芯”🧷✨
✅ EventStoreは「状態」じゃなく「出来事」を貯める📜
- いまのカートの中身はまだ作らない(それは15章で復元する🔁)
- いまは「出来事がちゃんと積めて、取り出せる」ことだけに集中🎯
✅ “イベント本体”と“保存用の包み”を分ける🍱
DomainEvent:起きた事実だけ(type + data)StoredEvent:保存の都合(eventId, occurredAt, streamId)も一緒
これ分けないと、後で「いつ起きた?」とか「重複した?」が追いにくくなるよ〜😵💫
5) ミニ演習💪🌸(手を動かすと一気に理解できる!)
演習A:別ストリームも作ってみよう🧺🧺
cart-002を作って、イベントを2個だけAppendしてみてね😊readStream("cart-001")とreadStream("cart-002")が混ざらないことを確認✅
演習B:readStreamの戻り値を壊せる?🧨→🛡️
const events = await store.readStream(cartId); events.push(...)を試してみて- もう一度
readStream(cartId)したときに、壊れてないことを確認😺✨ (コピーで返してるから守られるはず!)
演習C:イベントを0件Appendしたらどうなる?🪶
append(cartId, [])を呼んで、何も起きないことを確認✅- 「空イベントを入れない」って地味だけど大事😊
6) AI活用コーナー🤖✨(“丸投げ”じゃなく、型で使う!)
① 叩き台を作らせるプロンプト例🧱
- 「InMemoryEventStoreをMapで作って。appendとreadStreamだけ。配列はコピーで返して、外から壊れないようにして」📝✨
② レビューさせるプロンプト例🔎
- 「このEventStore実装、外から配列を破壊できる穴ない? eventId/occurredAtの付け方は変じゃない?」👀🛡️
③ “初心者向けに説明”させるプロンプト例📚
- 「DomainEventとStoredEventの違いを、専門用語少なめで説明して。例もつけて」🌸😊
7) よくあるつまずきポイント集🚧😵💫(先に潰す!)
つまずき①:event と stored event がごっちゃになる🍱
DomainEventは「出来事」StoredEventは「保管のために包んだもの」 → メタ情報(id/日時/streamId)は包みに入れるのが分かりやすいよ😊
つまずき②:readStreamで返した配列をいじって壊す🧨
- だから コピーで返すのが大事🛡️✨ (この章ではすでに対策済み😺)
つまずき③:イベント名が現在形になる😺→🙅♀️
AddItem❌(命令っぽい)ItemAdded✅(起きた出来事) イベントは「過去形」でね⏳✨
8) まとめ🎁✨
この章で、イベントソーシングの“心臓”の入口を作れたよ😊🫀
- EventStoreは 追記して✍️
- ストリーム単位で読む🔍 たったこれだけで、次の章から「順番」「復元」「コマンド処理の型」へ気持ちよく進める🚀✨
参考(2026年2月時点)📚🔗
- TypeScript の最新リリースノート(5.9)(TypeScript)
- Node.js のリリース状況(LTS/Currentの一覧)(Node.js)
- VS Code の 2026年1月アップデート(1.109)(Visual Studio Code)
- Vitest(TypeScriptをそのまま扱えるテスト環境として普及)(Vitest)