第28章:TDDっぽくイベントを育てる🌱🧪
この章のゴール🎯✨
- 「テスト → 実装 → 整理(リファクタ)」の流れで、ドメインイベントを小さく安全に進化させられる🧩🔧
OrderPlacedを 最小 → ちょい拡張 → きれいに整理 の3ステップで完成させる👣👣👣- “テストが仕様書になる感覚”をつかむ📘💖
まず「TDDっぽく」って何?🤔🧪
TDD(テスト駆動開発)は、ざっくり言うとこの繰り返しだよ〜👇✨
- Red 🔴:まずテストを書く(当然まだ落ちる)
- Green 🟢:最小の実装でテストを通す
- Refactor 🧹:動作を変えずにコードを整える(読みやすく・増やしやすく)
ドメインイベントと相性がいい理由はこれ👇
- 「この操作で何が起きた?」をテストで固定できる🧾✨
- 後から payload を足しても、壊れてないか即わかる✅
- “イベント名・粒度”の迷いも、テストがあると整理しやすい🧠🧹
ちなみに、2026年1月時点だと TypeScript の安定版は 5.9.3 が “Latest” 扱いだよ🧷✨ (GitHub) テストランナーは Vitest 4.0 が出ていて(2025/10)、今どき感あるよ〜⚡🧪 (Vitest)
今回の題材:OrderPlaced を育てる🛒🧾
「注文が確定した」っていう事実を、イベントとして育てるよ🌱✨
(ミニEC想定:注文確定=OrderPlaced)
この章では、ドメインがイベントを溜める → アプリ層が拾う流れで進めるよ🫙➡️📣
テスト環境(Vitest 4)を用意🧪⚙️
Vitest は公式で UIを別パッケージ(@vitest/ui)として追加できて、--ui で起動できるよ✨ (Vitest)
VS Code には Vitest を実行・デバッグできる拡張(Vitest Explorer)もあるよ🧩🖥️ (Visual Studio Marketplace)
最低限の例👇(すでに第26〜27章で入れてるなら読み飛ばしてOK🙆♀️)
// package.json(例)
{
"name": "mini-ec-domain-events",
"private": true,
"type": "module",
"scripts": {
"test": "vitest",
"test:watch": "vitest --watch",
"test:ui": "vitest --ui"
},
"devDependencies": {
"@types/node": "^24.0.0",
"typescript": "^5.9.3",
"vitest": "^4.0.0",
"@vitest/ui": "^4.0.0"
}
}
📝 ちょい注意:Vitest 4 はカバレッジ周りに変更も入ってるので、更新する時は移行ガイドを見るのがおすすめだよ🔍 (Vitest)
フォルダ構成(この章の最小セット)🗂️✨
src/
domain/
event.ts
aggregateRoot.ts
order.ts
application/
placeOrder.ts
test/
orderPlaced.spec.ts
ハンズオン:3ステップで OrderPlaced を完成させる👣🧪
Step 0:ベース(土台)を作る🧱✨
まずは「イベントの型」と「イベントを溜める仕組み」だけ用意するよ🫙🧩
src/domain/event.ts(イベント共通フォーマット)🧾🛡️
export type DomainEvent<TType extends string, TPayload> = Readonly<{
eventId: string;
type: TType;
occurredAt: string; // ISO文字列(例: 2026-01-27T...)
aggregateId: string;
payload: TPayload;
}>;
src/domain/aggregateRoot.ts(イベントを溜める)🫙✨
import type { DomainEvent } from "./event";
export abstract class AggregateRoot {
private _events: DomainEvent<string, unknown>[] = [];
protected raise(event: DomainEvent<string, unknown>) {
this._events.push(event);
}
pullDomainEvents(): DomainEvent<string, unknown>[] {
const events = this._events;
this._events = [];
return events;
}
}
Step 1:Red → Green(「イベントが出る」を最小で通す)🔴➡️🟢

1) まずテストを書く(Red)🔴🧪
「注文確定したら OrderPlaced が1個出る」だけチェックするよ✅
test/orderPlaced.spec.ts
import { describe, it, expect } from "vitest";
import { Order } from "../src/domain/order";
describe("OrderPlaced(Step1)", () => {
it("注文を確定すると OrderPlaced が1つ発生する", () => {
const order = Order.create("order-1");
order.place();
const events = order.pullDomainEvents();
expect(events).toHaveLength(1);
expect(events[0].type).toBe("OrderPlaced");
expect(events[0].aggregateId).toBe("order-1");
});
});
この時点では Order がないから落ちるよね🙂↕️(それでOK)
2) 最小実装で通す(Green)🟢🔧
src/domain/order.ts(Step1の最小版)🛒✨
import { AggregateRoot } from "./aggregateRoot";
import type { DomainEvent } from "./event";
import { randomUUID } from "node:crypto";
type OrderPlaced = DomainEvent<"OrderPlaced", {}>;
export class Order extends AggregateRoot {
private constructor(private readonly id: string) {
super();
}
static create(id: string) {
return new Order(id);
}
place() {
const event: OrderPlaced = {
eventId: randomUUID(),
type: "OrderPlaced",
occurredAt: new Date().toISOString(),
aggregateId: this.id,
payload: {},
};
this.raise(event);
}
}
✅ これで Step1 のテストは通るはず!
3) Step1の学びポイント🧠✨
- まずは payload を空
{}でもOK🙆♀️(最小で通すのがコツ) - 「イベントが出る」が仕様として固定された📌🧾
Step 2:Red → Green(payload を“必要最小限”で足す)🔴➡️🟢🎒
1) テストを追加(Red)🔴🧪
今度は「注文確定時に最低限ほしい情報」を payload に入れるよ🎒
例:customerId, total(合計)
test/orderPlaced.spec.ts(追記)
import { describe, it, expect } from "vitest";
import { Order } from "../src/domain/order";
describe("OrderPlaced(Step2)", () => {
it("OrderPlaced payload に customerId と total が入る", () => {
const order = Order.create("order-2", { customerId: "cust-9" });
order.place(1200);
const [event] = order.pullDomainEvents();
expect(event.type).toBe("OrderPlaced");
expect(event.payload.customerId).toBe("cust-9");
expect(event.payload.total).toBe(1200);
});
});
2) 実装を最小で更新(Green)🟢🔧
src/domain/order.ts(Step2版)🛒💳✨
import { AggregateRoot } from "./aggregateRoot";
import type { DomainEvent } from "./event";
import { randomUUID } from "node:crypto";
type OrderPlacedPayload = {
customerId: string;
total: number;
};
type OrderPlaced = DomainEvent<"OrderPlaced", OrderPlacedPayload>;
export class Order extends AggregateRoot {
private constructor(
private readonly id: string,
private readonly customerId: string,
) {
super();
}
static create(id: string, args?: { customerId: string }) {
const customerId = args?.customerId ?? "guest";
return new Order(id, customerId);
}
place(total: number = 0) {
const event: OrderPlaced = {
eventId: randomUUID(),
type: "OrderPlaced",
occurredAt: new Date().toISOString(),
aggregateId: this.id,
payload: {
customerId: this.customerId,
total,
},
};
this.raise(event);
}
}
✅ Step2 も通る〜!🎉
3) Step2の学びポイント🧠🎒
- payload は「今ほんとに必要な最小」でOK(盛りすぎ注意⚠️)
- テストを足すと、“イベント契約”が育つ感じが出る🌱📜
Step 3:Refactor(壊さず整理して、育てやすくする)🧹✨
ここからが「TDDっぽい」おいしいところ😋 テストが通ってる状態で、安心して整理できるよ✅🧹
ありがちなモヤモヤ😵💫
new Date()がテストでブレそうrandomUUID()がテストで追いにくいplace()がイベント生成の詳細を持ちすぎてる
ここでやる整理の方向性🧭✨
- “時間”と“ID”を外から渡せるようにして、テストを安定させる🕰️🧾
- イベント生成を1か所にまとめて読みやすくする📦✨
1) Clock と IdGenerator を用意(小さく)🕰️🧾
src/domain/order.ts(Step3版:整理)🧹✨
import { AggregateRoot } from "./aggregateRoot";
import type { DomainEvent } from "./event";
type OrderPlacedPayload = {
customerId: string;
total: number;
};
type OrderPlaced = DomainEvent<"OrderPlaced", OrderPlacedPayload>;
type Clock = { nowIso(): string };
type IdGenerator = { newId(): string };
const systemClock: Clock = { nowIso: () => new Date().toISOString() };
export class Order extends AggregateRoot {
private constructor(
private readonly id: string,
private readonly customerId: string,
private readonly clock: Clock,
private readonly idGen: IdGenerator,
) {
super();
}
static create(
id: string,
args?: {
customerId: string;
clock?: Clock;
idGen?: IdGenerator;
},
) {
const customerId = args?.customerId ?? "guest";
const clock = args?.clock ?? systemClock;
const idGen =
args?.idGen ?? { newId: () => crypto.randomUUID() }; // NodeのWeb Crypto
return new Order(id, customerId, clock, idGen);
}
place(total: number = 0) {
this.raise(this.buildOrderPlaced(total));
}
private buildOrderPlaced(total: number): OrderPlaced {
return {
eventId: this.idGen.newId(),
type: "OrderPlaced",
occurredAt: this.clock.nowIso(),
aggregateId: this.id,
payload: { customerId: this.customerId, total },
};
}
}
💡
crypto.randomUUID()は Node 側の Web Crypto でも使えるよ(最近の Node ならOK)🧾✨ ちなみに Node は LTS を使うのが無難で、2026年1月時点だと v24 が Active LTS だよ🟩 (Node.js)
2) テストも「安定化」できる✅🧪
時間とIDが固定できると、テストが気持ちいいくらい安定するよ〜🧡
test/orderPlaced.spec.ts(Step3用の例)
import { describe, it, expect } from "vitest";
import { Order } from "../src/domain/order";
describe("OrderPlaced(Step3)", () => {
it("occurredAt と eventId を固定してテストできる", () => {
const fixedClock = { nowIso: () => "2026-01-27T00:00:00.000Z" };
const fixedIdGen = { newId: () => "evt-1" };
const order = Order.create("order-3", {
customerId: "cust-1",
clock: fixedClock,
idGen: fixedIdGen,
});
order.place(500);
const [event] = order.pullDomainEvents();
expect(event.eventId).toBe("evt-1");
expect(event.occurredAt).toBe("2026-01-27T00:00:00.000Z");
});
});
仕上げ:アプリ層で「イベントを拾う」ミニ例📣🚚
「ドメインは溜めるだけ」だったよね?🫙 最後にアプリ層が拾って “次の処理” に回すイメージだけつけよう〜✨
src/application/placeOrder.ts(超ミニ)🧩
import { Order } from "../domain/order";
type OrderRepository = {
save(order: Order): Promise<void>;
};
type EventDispatcher = {
dispatch(events: { type: string }[]): Promise<void>;
};
export class PlaceOrderUseCase {
constructor(
private readonly repo: OrderRepository,
private readonly dispatcher: EventDispatcher,
) {}
async execute(input: { orderId: string; customerId: string; total: number }) {
const order = Order.create(input.orderId, { customerId: input.customerId });
order.place(input.total);
await this.repo.save(order);
const events = order.pullDomainEvents();
await this.dispatcher.dispatch(events);
}
}
🧠 ポイント:保存のあとに dispatch する流れは「保存できた事実に寄せる」ためだよ💾➡️📣 (Outbox まで行くのは次の章以降の世界観だね🗃️✨)
AI活用(この章向け)🤖💬✨
1) “次の最小ステップ” を聞く🧭
- 「今このテストが落ちてる。最小で通す実装だけ提案して」
- 「payload を足したい。入れるべき最小項目を3案で」
2) リファクタ案を出してもらう🧹
- 「この
place()が読みにくい。責務を減らす分割案を3つ」 - 「Clock/Id を注入したい。やりすぎない最小DIで」
3) テスト名を整える📛
- 「このテスト名、読みやすい日本語と英語で候補10個ちょうだい」
演習📝💖(3ステップを自分の手で)
演習A:OrderPlaced を“最小→拡張→整理”で作る👣✨
- Step1:
OrderPlacedが出るだけ - Step2:payload に
customerIdとtotal - Step3:Clock/Id注入でテストを安定化
演習B:もう1イベント増やす🌱➕
OrderCancelled を同じ流れで追加してみよう🧾
- Step1:イベントが出る
- Step2:payload に
reason(キャンセル理由) - Step3:イベント生成を
buildOrderCancelled()に分離
つまずきチェックリスト✅🧠
- テストが「実装の細部」まで縛ってない?(縛りすぎると育てにくい)🔒😵💫
- payload を盛りすぎてない?(参照で取れる情報は入れすぎ注意)🎒⚠️
- “時間”や“ID”がブレてテストが不安定になってない?🕰️🧾
- 「Red→Green→Refactor」の順番を守れてる?(Refactorを先にしがち)🧹🫣
まとめ🌷✨
- まずは小さくテストで固定して、イベントを安全に育てる🧪🧾
- payload は必要最小限から始めて、テストで契約を育てる📜🌱
- テストが通ってる状態なら、リファクタは怖くない🧹💖