ストーリー
演習の概要
オンライン書店のドメインをDDDでモデリングしてください。
| 項目 | 内容 |
|---|---|
| テーマ | オンライン書店 |
| 手法 | DDD(戦略的設計 + 戦術的設計) |
| ミッション数 | 6 |
| 推定時間 | 90分 |
オンライン書店の業務概要
- 顧客が書籍を検索・閲覧し、カートに追加して注文する
- 決済はクレジットカードまたは銀行振込
- 注文確定後、倉庫から出荷される
- 顧客にはポイントが付与され、次回以降の購入で使える
- 書籍にはレビューを投稿できる
Mission 1: イベントストーミングを行おう(15分)
ドメインイベントを洗い出し、コマンドとアクターを特定してください。
解答例
ドメインイベント:
- 書籍がカタログに追加された
- 書籍が検索された
- カートに書籍が追加された
- カートから書籍が削除された
- 注文が作成された
- 決済が完了した
- 決済が失敗した
- 在庫が引き当てられた
- 在庫が不足した
- 注文が出荷された
- 注文が配達完了した
- 注文がキャンセルされた
- ポイントが付与された
- ポイントが使用された
- レビューが投稿された
コマンド → イベント:
- カートに追加する → カートに書籍が追加された
- 注文を確定する → 注文が作成された
- 決済を実行する → 決済が完了した / 決済が失敗した
- 出荷を指示する → 注文が出荷された
- レビューを投稿する → レビューが投稿された
アクター:
- 顧客: 検索、カート操作、注文、レビュー投稿
- 管理者: カタログ管理
- 倉庫スタッフ: 出荷指示
- システム: 決済実行、ポイント付与、在庫引当
Mission 2: Bounded Contextを特定しよう(15分)
イベントストーミングの結果から、Bounded Contextを特定してください。
解答例
┌───────────────────────────────────────────────┐
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │カタログBC │ │ 注文BC │ │ 決済BC │ │
│ │ │ │ │ │ │ │
│ │ 書籍情報 │ │ カート │ │ 決済処理 │ │
│ │ 検索 │ │ 注文管理 │ │ 返金 │ │
│ │ カテゴリ │ │ │ │ │ │
│ └──────────┘ └──────────┘ └──────────┘ │
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ 在庫BC │ │ 配送BC │ │ レビューBC │ │
│ │ │ │ │ │ │ │
│ │ 在庫管理 │ │ 出荷 │ │ レビュー │ │
│ │ 入荷 │ │ 配達追跡 │ │ 評価 │ │
│ └──────────┘ └──────────┘ └──────────┘ │
│ │
│ ┌──────────┐ │
│ │ポイントBC │ │
│ │ │ │
│ │ ポイント │ │
│ │ 付与/使用│ │
│ └──────────┘ │
└───────────────────────────────────────────────┘
7つのBounded Context:
- カタログBC: 書籍情報、検索、カテゴリ管理
- 注文BC: カート、注文作成・管理
- 決済BC: クレジットカード決済、銀行振込、返金
- 在庫BC: 在庫数管理、入荷、引当
- 配送BC: 出荷指示、配達追跡
- レビューBC: レビュー投稿、評価
- ポイントBC: ポイント付与、使用
Mission 3: Aggregateを設計しよう(20分)
注文BCのAggregateを設計してください。
解答例
// 注文BC: Order Aggregate
// ┌──────────────────────────────────┐
// │ Order (Aggregate Root) │
// │ ├── CartItem (Value Object) │
// │ └── ShippingAddress (VO) │
// └──────────────────────────────────┘
// Value Objects
class BookId {
private constructor(private readonly _value: string) {}
static of(value: string): BookId { return new BookId(value); }
get value(): string { return this._value; }
equals(other: BookId): boolean { return this._value === other._value; }
}
class CartItem {
private constructor(
readonly bookId: BookId,
readonly title: string,
private _quantity: number,
readonly unitPrice: Money
) {
if (_quantity <= 0) throw new Error('数量は1以上です');
if (_quantity > 10) throw new Error('1冊あたり最大10冊まで');
}
static of(bookId: string, title: string, qty: number, price: Money): CartItem {
return new CartItem(BookId.of(bookId), title, qty, price);
}
get quantity(): number { return this._quantity; }
get subtotal(): Money {
return this.unitPrice.multiply(this._quantity);
}
withIncreasedQuantity(amount: number): CartItem {
return new CartItem(this.bookId, this.title, this._quantity + amount, this.unitPrice);
}
}
class ShippingAddress {
private constructor(
readonly postalCode: string,
readonly prefecture: string,
readonly city: string,
readonly line1: string,
readonly line2: string | null,
readonly recipientName: string
) {
if (!postalCode.match(/^\d{3}-\d{4}$/)) {
throw new Error('郵便番号の形式が正しくありません');
}
if (!recipientName) throw new Error('宛名は必須です');
}
static of(data: {
postalCode: string; prefecture: string; city: string;
line1: string; line2?: string; recipientName: string;
}): ShippingAddress {
return new ShippingAddress(
data.postalCode, data.prefecture, data.city,
data.line1, data.line2 ?? null, data.recipientName
);
}
}
// Aggregate Root
class Order {
private _domainEvents: DomainEvent[] = [];
private constructor(
private readonly _id: OrderId,
private readonly _customerId: string,
private _items: CartItem[],
private _shippingAddress: ShippingAddress,
private _status: OrderStatus,
private _pointsUsed: number,
private readonly _createdAt: Date
) {}
static create(
customerId: string,
items: CartItem[],
address: ShippingAddress,
pointsUsed: number = 0
): Order {
if (items.length === 0) throw new Error('最低1冊の書籍が必要です');
const order = new Order(
OrderId.generate(), customerId, items, address,
OrderStatus.PENDING, pointsUsed, new Date()
);
order._domainEvents.push(
new OrderCreatedEvent(order._id.value, customerId, items)
);
return order;
}
get totalAmount(): Money {
const subtotal = this._items.reduce(
(sum, item) => sum.add(item.subtotal), Money.zero('JPY')
);
return subtotal.subtract(Money.of(this._pointsUsed, 'JPY'));
}
confirm(): void {
if (this._status !== OrderStatus.PENDING) {
throw new Error('保留中の注文のみ確認できます');
}
this._status = OrderStatus.CONFIRMED;
this._domainEvents.push(
new OrderConfirmedEvent(this._id.value)
);
}
cancel(reason: string): void {
if (this._status === OrderStatus.SHIPPED || this._status === OrderStatus.DELIVERED) {
throw new Error('出荷済み/配達完了の注文はキャンセルできません');
}
this._status = OrderStatus.CANCELLED;
this._domainEvents.push(
new OrderCancelledEvent(this._id.value, reason)
);
}
pullDomainEvents(): DomainEvent[] {
const events = [...this._domainEvents];
this._domainEvents = [];
return events;
}
}
Mission 4: コンテキストマップを描こう(15分)
BC間の関係を特定し、コンテキストマップを描いてください。
解答例
┌──────────┐ OHS/PL ┌──────────┐
│カタログBC │────────→│ 注文BC │
│ │ │ │
└──────────┘ └────┬─────┘
│ PL
┌──────┼──────┐
│ │ │
┌────▼──┐ ┌▼────┐ ┌▼────────┐
│決済BC │ │在庫BC│ │ポイントBC│
│ │ │ │ │ │
└──┬───┘ └─────┘ └─────────┘
│ ACL
┌──▼────────┐
│外部決済API │
│(Stripe等) │
└───────────┘
┌──────────┐ ACL ┌──────────┐
│ 在庫BC │──────→│外部配送API│
└──────────┘ └──────────┘
┌──────────┐
│レビューBC │ ※独立性が高い(他BCとの結合が弱い)
└──────────┘
関係パターン:
- カタログ → 注文: OHS/PL(書籍情報をAPIで提供)
- 注文 → 決済: U/D(Customer-Supplier)
- 注文 → 在庫: PL(OrderCreatedEventで連携)
- 注文 → ポイント: PL(ポイント付与イベントで連携)
- 決済 → 外部決済API: ACL(Stripe等の変換層)
- 在庫 → 外部配送API: ACL(配送業者APIの変換層)
Mission 5: ドメインイベントとハンドラを実装しよう(15分)
注文BCのドメインイベントと、在庫BC・ポイントBCのイベントハンドラを実装してください。
解答例
// 注文BC: ドメインイベント
class OrderCreatedEvent implements DomainEvent {
readonly eventType = 'order.created';
readonly eventId = crypto.randomUUID();
readonly occurredAt = new Date();
constructor(
readonly orderId: string,
readonly customerId: string,
readonly items: Array<{ bookId: string; quantity: number }>
) {}
}
class OrderConfirmedEvent implements DomainEvent {
readonly eventType = 'order.confirmed';
readonly eventId = crypto.randomUUID();
readonly occurredAt = new Date();
constructor(readonly orderId: string) {}
}
// 在庫BC: イベントハンドラ
class StockReservationHandler {
constructor(private stockRepo: StockItemRepository) {}
async onOrderConfirmed(event: OrderConfirmedEvent): Promise<void> {
// 注文確認時に在庫を引き当てる
// (実際にはOrder情報を取得する必要がある)
console.log(`在庫引当: 注文${event.orderId}`);
}
}
// ポイントBC: イベントハンドラ
class PointGrantHandler {
constructor(private pointRepo: PointAccountRepository) {}
async onOrderDelivered(event: OrderDeliveredEvent): Promise<void> {
// 配達完了時にポイントを付与
// 購入金額の1% をポイントとして付与
const pointAccount = await this.pointRepo.findByCustomerId(
event.customerId
);
if (pointAccount) {
const points = Math.floor(event.totalAmount * 0.01);
pointAccount.grant(points, `注文${event.orderId}のポイント付与`);
await this.pointRepo.save(pointAccount);
}
}
}
Mission 6: ADRを書こう(10分)
「オンライン書店のBounded Context分割」に関するADRを書いてください。
解答例
# ADR-001: オンライン書店のBounded Context分割
## ステータス
承認済み
## コンテキスト
オンライン書店のバックエンドを新規開発する。
書籍のカタログ管理、注文、決済、在庫、配送、レビュー、ポイントの
各業務領域がある。チーム規模は6名。
## 決定
7つのBounded Contextに分割する:
カタログ、注文、決済、在庫、配送、レビュー、ポイント。
初期段階ではモジュラーモノリスとして実装し、将来的にマイクロサービスに分割可能な構造にする。
## 理由
- イベントストーミングで明確に異なるユビキタス言語が特定された
- 「商品」が各コンテキストで異なる意味を持つことが確認された
- チーム規模に対してマイクロサービスは過剰だが、境界は明確にしたい
- モジュラーモノリスならデプロイの複雑さを避けつつ境界を維持できる
## 却下した選択肢
- 3BC分割(カタログ+注文+在庫を1つに): ドメインの複雑さが隠れる
- マイクロサービス: チーム規模に対してオーバーエンジニアリング
## 結果
各BCが独立したモジュールとして開発でき、チーム分担が明確になった。
達成度チェック
| Mission | 内容 | 完了 |
|---|---|---|
| 1 | イベントストーミング | [ ] |
| 2 | Bounded Context特定 | [ ] |
| 3 | Aggregate設計 | [ ] |
| 4 | コンテキストマップ | [ ] |
| 5 | ドメインイベントとハンドラ | [ ] |
| 6 | ADR作成 | [ ] |
チェックリスト
- イベントストーミングで主要なイベントを洗い出せた
- ユビキタス言語の境界からBCを特定できた
- Aggregateのルール(Root経由、トランザクション境界)を守った
- コンテキストマップで適切な関係パターンを選択できた
- ドメインイベントが過去形で命名されている
次のステップへ
演習お疲れさまでした。次はStep 4のチェックポイントクイズに挑戦しましょう。
推定所要時間: 90分