EXERCISE 90分

ストーリー

高橋アーキテクト
DDDの理論は一通り学んだ。ここからは実践だ
高橋アーキテクト
“オンライン書店”のドメインをモデリングしてくれ。イベントストーミングからBounded Contextの特定、Aggregateの設計、ドメインイベントの定義まで。フルコースだ

演習の概要

オンライン書店のドメインをDDDでモデリングしてください。

項目内容
テーマオンライン書店
手法DDD(戦略的設計 + 戦術的設計)
ミッション数6
推定時間90分

オンライン書店の業務概要

  • 顧客が書籍を検索・閲覧し、カートに追加して注文する
  • 決済はクレジットカードまたは銀行振込
  • 注文確定後、倉庫から出荷される
  • 顧客にはポイントが付与され、次回以降の購入で使える
  • 書籍にはレビューを投稿できる

Mission 1: イベントストーミングを行おう(15分)

ドメインイベントを洗い出し、コマンドとアクターを特定してください。

解答例

ドメインイベント:

  • 書籍がカタログに追加された
  • 書籍が検索された
  • カートに書籍が追加された
  • カートから書籍が削除された
  • 注文が作成された
  • 決済が完了した
  • 決済が失敗した
  • 在庫が引き当てられた
  • 在庫が不足した
  • 注文が出荷された
  • 注文が配達完了した
  • 注文がキャンセルされた
  • ポイントが付与された
  • ポイントが使用された
  • レビューが投稿された

コマンド → イベント:

  • カートに追加する → カートに書籍が追加された
  • 注文を確定する → 注文が作成された
  • 決済を実行する → 決済が完了した / 決済が失敗した
  • 出荷を指示する → 注文が出荷された
  • レビューを投稿する → レビューが投稿された

アクター:

  • 顧客: 検索、カート操作、注文、レビュー投稿
  • 管理者: カタログ管理
  • 倉庫スタッフ: 出荷指示
  • システム: 決済実行、ポイント付与、在庫引当

Mission 2: Bounded Contextを特定しよう(15分)

イベントストーミングの結果から、Bounded Contextを特定してください。

解答例
┌───────────────────────────────────────────────┐
│                                               │
│  ┌──────────┐  ┌──────────┐  ┌──────────┐   │
│  │カタログBC │  │ 注文BC   │  │ 決済BC   │   │
│  │          │  │          │  │          │   │
│  │ 書籍情報 │  │ カート   │  │ 決済処理 │   │
│  │ 検索    │  │ 注文管理 │  │ 返金    │   │
│  │ カテゴリ │  │          │  │          │   │
│  └──────────┘  └──────────┘  └──────────┘   │
│                                               │
│  ┌──────────┐  ┌──────────┐  ┌──────────┐   │
│  │ 在庫BC   │  │ 配送BC   │  │ レビューBC │   │
│  │          │  │          │  │          │   │
│  │ 在庫管理 │  │ 出荷    │  │ レビュー │   │
│  │ 入荷    │  │ 配達追跡 │  │ 評価    │   │
│  └──────────┘  └──────────┘  └──────────┘   │
│                                               │
│  ┌──────────┐                                │
│  │ポイントBC │                                │
│  │          │                                │
│  │ ポイント │                                │
│  │ 付与/使用│                                │
│  └──────────┘                                │
└───────────────────────────────────────────────┘

7つのBounded Context:

  1. カタログBC: 書籍情報、検索、カテゴリ管理
  2. 注文BC: カート、注文作成・管理
  3. 決済BC: クレジットカード決済、銀行振込、返金
  4. 在庫BC: 在庫数管理、入荷、引当
  5. 配送BC: 出荷指示、配達追跡
  6. レビューBC: レビュー投稿、評価
  7. ポイント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イベントストーミング[ ]
2Bounded Context特定[ ]
3Aggregate設計[ ]
4コンテキストマップ[ ]
5ドメインイベントとハンドラ[ ]
6ADR作成[ ]

チェックリスト

  • イベントストーミングで主要なイベントを洗い出せた
  • ユビキタス言語の境界からBCを特定できた
  • Aggregateのルール(Root経由、トランザクション境界)を守った
  • コンテキストマップで適切な関係パターンを選択できた
  • ドメインイベントが過去形で命名されている

次のステップへ

演習お疲れさまでした。次はStep 4のチェックポイントクイズに挑戦しましょう。


推定所要時間: 90分