LESSON 40分

ストーリー

高橋アーキテクト
戦略的設計でBounded Contextを切り分けた。次は、その中のコードをどう設計するかだ
高橋アーキテクト
戦術的設計には、Entity、Value Object、Aggregate、Domain Service、Repositoryなどのパターンがある。Step 2で少し触れたけど、ここではDDDの観点から深く学ぼう

Entity(エンティティ)

**同一性(Identity)**を持つオブジェクトです。属性が変わっても「同じもの」として扱います。

class Order {
  constructor(
    private readonly _id: OrderId,  // 同一性を定義するID
    private readonly _customerId: string,
    private _items: OrderItem[],
    private _status: OrderStatus
  ) {}

  // IDが同じなら同一のエンティティ
  equals(other: Order): boolean {
    return this._id.equals(other._id);
  }

  // 属性は変わるが、IDが同じなら同じ注文
  confirm(): void {
    this._status = OrderStatus.CONFIRMED;
  }
}

Value Object(値オブジェクト)

値そのもので識別されるオブジェクトです。不変で、自己検証を行います。

class Money {
  private constructor(
    private readonly _amount: number,
    private readonly _currency: string
  ) {
    if (_amount < 0) throw new Error('金額は0以上');
    if (!['JPY', 'USD', 'EUR'].includes(_currency)) {
      throw new Error('サポートされていない通貨');
    }
  }

  static of(amount: number, currency: string): Money {
    return new Money(amount, currency);
  }

  // 値が同じなら等しい(IDではなく値で比較)
  equals(other: Money): boolean {
    return this._amount === other._amount && this._currency === other._currency;
  }

  // 不変: 新しいインスタンスを返す
  add(other: Money): Money {
    if (this._currency !== other._currency) {
      throw new Error('異なる通貨の加算はできません');
    }
    return new Money(this._amount + other._amount, this._currency);
  }
}

class EmailAddress {
  private constructor(private readonly _value: string) {
    const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
    if (!emailRegex.test(_value)) {
      throw new Error('無効なメールアドレスです');
    }
  }

  static of(value: string): EmailAddress {
    return new EmailAddress(value);
  }

  get value(): string { return this._value; }

  equals(other: EmailAddress): boolean {
    return this._value === other._value;
  }
}

EntityとValue Objectの使い分け

判断基準EntityValue Object
同一性IDで識別値で識別
可変性状態が変わる不変
ライフサイクル生成→変更→削除生成→破棄(変更は新規生成)
Order, Customer, ProductMoney, EmailAddress, DateRange

Aggregate(集約)

Aggregateは、データの一貫性を保つ単位です。Aggregate Root(集約ルート)を通じてのみアクセスします。

// Order Aggregate
// ┌──────────────────────────────────┐
// │  Order(Aggregate Root)          │
// │  ├── OrderItem                   │
// │  ├── OrderItem                   │
// │  └── ShippingAddress             │
// └──────────────────────────────────┘

class Order {
  private constructor(
    private readonly _id: OrderId,
    private readonly _customerId: string,
    private _items: OrderItem[],           // Aggregate内のEntity
    private _shippingAddress: Address,     // Aggregate内のValue Object
    private _status: OrderStatus
  ) {}

  // Aggregate Rootを通じてのみ内部を操作する
  addItem(productId: string, name: string, qty: number, price: Money): void {
    if (this._status !== OrderStatus.PENDING) {
      throw new Error('保留中の注文にのみ商品を追加できます');
    }
    if (this._items.length >= 20) {
      throw new Error('1つの注文に含められる商品は20個までです');
    }

    const existingItem = this._items.find(i => i.productId === productId);
    if (existingItem) {
      existingItem.increaseQuantity(qty);
    } else {
      this._items.push(OrderItem.create(productId, name, qty, price));
    }
  }

  removeItem(productId: string): void {
    if (this._status !== OrderStatus.PENDING) {
      throw new Error('保留中の注文からのみ商品を削除できます');
    }
    this._items = this._items.filter(i => i.productId !== productId);
    if (this._items.length === 0) {
      throw new Error('注文には最低1つの商品が必要です');
    }
  }
}

// NG: Aggregate Rootを経由せずに内部を操作
// order.items[0].quantity = 5;  // 直接アクセス禁止

// OK: Aggregate Rootのメソッドを通じて操作
// order.addItem('prod-1', '商品名', 5, Money.of(1000, 'JPY'));

Aggregateの設計ルール

ルール説明
Aggregate Rootを経由外部からはRootのメソッドのみ呼び出す
トランザクション境界1つのトランザクションで1つのAggregateのみ更新
小さく保つAggregateは必要最小限のEntityを含める
他Aggregateの参照IDでのみ参照する(オブジェクト参照は持たない)
// Aggregate間はIDで参照する
class Order {
  private readonly _customerId: string;  // Customer Aggregateへの参照はIDのみ

  // NG: 他のAggregateのオブジェクトを直接保持
  // private readonly _customer: Customer;
}

Domain Service(ドメインサービス)

特定のEntityに属さないビジネスロジックを配置します。

// 割引計算は注文にも顧客にも属さない
class DiscountCalculator {
  calculate(order: Order, customer: Customer): Money {
    let discount = Money.zero('JPY');

    // VIP顧客は10%割引
    if (customer.isVip()) {
      discount = order.totalAmount.multiply(0.1);
    }

    // 1万円以上の注文は500円割引
    if (order.totalAmount.amount >= 10000) {
      discount = discount.add(Money.of(500, 'JPY'));
    }

    return discount;
  }
}

Domain Serviceの判断基準

graph TD
    Q{{"このロジックはどのEntityに属する?"}}
    A["Entityのメソッド"]
    B["Domain Service"]
    C["Application Service<br/>(Use Case)"]

    Q -->|"特定のEntityに自然に属する"| A
    Q -->|"複数のEntityにまたがる"| B
    Q -->|"外部リソースが必要"| C

Repository(リポジトリ)

Aggregate単位でリポジトリを定義します。

// Aggregate Root 1つ = Repository 1つ
interface OrderRepository {
  findById(id: OrderId): Promise<Order | null>;
  save(order: Order): Promise<void>;
  delete(id: OrderId): Promise<void>;
}

// NG: Aggregate内のEntityに個別のRepositoryを作らない
// interface OrderItemRepository {
//   findById(id: OrderItemId): Promise<OrderItem | null>;
// }
// OrderItemはOrder Aggregateの一部なので、OrderRepositoryを通じてアクセス

ディレクトリ構造(DDD + ヘキサゴナル)

src/
├── ordering/                    # 注文 Bounded Context
│   ├── domain/
│   │   ├── entities/
│   │   │   ├── Order.ts         # Aggregate Root
│   │   │   └── OrderItem.ts     # Aggregate内Entity
│   │   ├── value-objects/
│   │   │   ├── OrderId.ts
│   │   │   ├── Money.ts
│   │   │   └── Address.ts
│   │   ├── services/
│   │   │   └── DiscountCalculator.ts
│   │   └── ports/
│   │       ├── in/
│   │       └── out/OrderRepository.ts
│   ├── application/
│   │   └── CreateOrderUseCase.ts
│   └── adapters/
│       ├── in/OrderController.ts
│       └── out/PrismaOrderRepository.ts
└── inventory/                   # 在庫 Bounded Context
    ├── domain/
    │   ├── entities/StockItem.ts
    │   └── ...
    └── ...

まとめ

ポイント内容
Entity同一性(ID)を持つ、状態が変化するオブジェクト
Value Object値で識別される不変オブジェクト
Aggregateデータ一貫性の単位、Rootを通じてのみアクセス
Domain Service特定のEntityに属さないビジネスロジック
RepositoryAggregate Root単位で定義

チェックリスト

  • EntityとValue Objectの違いを説明できる
  • Aggregateの4つのルールを把握した
  • Domain Serviceの使いどころを判断できる
  • Aggregate Root単位でRepositoryを定義する理由を理解した

次のステップへ

次は「ドメインイベントとイベントストーミング」を学びます。Bounded Context間の連携方法と、ドメインを発見するためのワークショップ手法を身につけましょう。


推定読了時間: 40分