Aggregate Root

Aggregate Rootとは

Aggregate Rootは、Aggregateを代表するEntityです。Aggregateの状態を変えるときは、Aggregate Rootのメソッドを呼びます。Aggregate Rootは、必要な業務ルールを確認してから処理します。

Aggregate Root専用の種類があるわけではありません。Aggregate内のEntityの一つが、この役割を担います。

具体例で整理する

前のページでは、「募集」を一つのAggregateとして扱う範囲を決めました。このAggregateに含まれるEntityは「募集」だけです。そのため、「募集」がAggregate Rootになります。

Aggregateによっては、複数のEntityが含まれることもあります。その場合は、Aggregate全体への操作を受け付けるEntityを一つだけAggregate Rootにし、ほかのEntityはAggregate内部のEntityとして扱います。

「募集」は、「参加を申請する」「開催場所を変更する」「募集を締め切る」といった操作を受け付け、必要なルールを確認してから処理します。

例えば、今日20時開催、定員2人、申込期限18時の募集に、参加希望者が17時に参加を申請したとします。現在の参加者は1人で、この人からの申請はまだありません。

システムは、対象となる「募集」に参加申請を依頼します。「募集」がルールを確認し、参加できる場合だけ参加者を追加します。

一つの参加申請を受け付ける流れ

1 参加を申請する

参加希望者が17時に申請

2 募集がルールを確認する

募集

Aggregate Root

  • ・申請時刻17時は、申込期限18時より前
  • ・同じ人の参加申請はない
  • ・現在の参加者は1人で、定員2人に達していない

3 参加を確定する

参加者として募集へ追加

参加者2人/定員2人

なぜAggregate Rootから操作するのか

参加申請をAggregateの外から直接追加できると、申込期限や定員を確認せずに受け付けられてしまいます。参加申請の受付を「募集」に任せれば、参加申請を受け付けるたびに同じルールを確認できます。

コードで表す

実際のRecruitmentEntityでは、参加申請を受け付ける処理を次のように書いています。

// 「募集」のEntityがAggregate Rootの役割を担う
export class RecruitmentEntity {
  requestParticipation(input: {
    requesterId: ParticipantIdValueObject;
    now: Date;
  }): void {
    // 受付中の募集でなければ、参加申請を受け付けない
    this.assertOpen();

    // 開催日時を過ぎていたら、参加申請を受け付けない
    if (input.now >= this.props.eventDateTime) {
      throw new RecruitmentRuleError(
        "EVENT_ALREADY_STARTED",
        "開催済みの募集には参加申請できません",
      );
    }

    // 申込期限を過ぎていたら、参加申請を受け付けない
    if (this.props.participationRequestDeadline.hasPassed(input.now)) {
      throw new RecruitmentRuleError(
        "PARTICIPATION_REQUEST_DEADLINE_PASSED",
        "申込期限を過ぎています",
      );
    }

    // 主催者自身からの参加申請は受け付けない
    if (input.requesterId.value === this.props.organizerId) {
      throw new RecruitmentRuleError(
        "ORGANIZER_CANNOT_REQUEST_PARTICIPATION",
        "主催者自身は参加申請できません",
      );
    }

    // 同じ人の参加申請があれば、二重に受け付けない
    if (
      this.participantIdValues.some((participantId) =>
        participantId.equals(input.requesterId),
      )
    ) {
      throw new RecruitmentRuleError(
        "DUPLICATE_PARTICIPATION_REQUEST",
        "同じ募集へ二重に参加申請できません",
      );
    }

    // 定員に達していたら、参加申請を受け付けない
    if (this.props.capacity.isReached(this.confirmedParticipantCount)) {
      throw new RecruitmentRuleError(
        "CAPACITY_REACHED",
        "定員に達している募集には参加申請できません",
      );
    }

    // 条件を満たした場合は、その場で参加が確定する
    this.participantIdValues.push(input.requesterId);
  }
}

requestParticipation()の中で参加者を追加することで、ルールを確認せずに参加が確定することを防ぎます。

補足:Entityが複数ある場合

募集Aggregateでは、EntityはRecruitmentEntity一つだけでした。しかし、Aggregateに含まれるEntityが、常に一つとは限りません。

例えば注文では、「注文」と、その注文に含まれる複数の「注文明細」を一つのAggregateとして扱います。注文明細は一件ずつ明細IDで区別し、数量が変わっても同じ明細として管理するため、それぞれをOrderLineEntityとして表します。この場合、注文AggregateにはOrderEntityと複数のOrderLineEntityが含まれます。

Entityが複数あっても、Aggregate Rootは一つです。注文は、複数の注文明細を含めて一つの単位として作成・確定・取消します。また、注文明細だけでは、注文が確定済みか、ほかの明細を含めた合計数量や金額がいくつかを確認できません。

そのため、注文全体の情報を持ち、「商品を追加する」「明細の数量を変更する」「注文を確定する」といった操作を受け付けるOrderEntityをAggregate Rootにします。明細の数量を変更するときも、OrderEntityが注文全体のルールを確認してからOrderLineEntityを変更します。

ディレクトリで表す

src/features/futsal/
└── domain/
    └── recruitment/
        ├── RecruitmentEntity.ts                  ← Aggregate Root
        ├── RecruitmentStatusValueObject.ts
        ├── RecruitmentCapacityValueObject.ts
        ├── ParticipantIdValueObject.ts
        └── ParticipationRequestDeadlineValueObject.ts

※ Aggregate RootはEntityに与えられる役割なので、クラス名にはAggregateRootを付けず、注釈で示しています。