目次を表示する

EvansとVernonで学ぶDDD

[比較] 集約(Aggregate)── Evansの原典とVernonの4つのルール

[比較] 集約(Aggregate)── Evansの原典とVernonの4つのルール

Evansの原典定義

Evansは青本の中で集約を次のように定義した。「データ変更を目的として1つの単位として扱う、関連オブジェクトのクラスター(cluster of associated objects that we treat as a unit for the purpose of data changes)」。

この定義が含む要素は2つある。第一に、集約は単一のオブジェクトではなく、複数のオブジェクトで構成されるクラスターだ。第二に、そのクラスターを変更するときは「1つの単位」として扱う。部分的な変更を外部から直接適用することは許されない。

クラスターの入口となるオブジェクトが集約ルートだ。外部のオブジェクトは集約ルートのみを参照できる。集約の内部にある他のエンティティや値オブジェクトへの直接参照は許されない。集約ルートが不変条件(invariants)の維持に全責任を持つ。

採用管理システムの選考管理コンテキストで考えると、ScreeningProcess(選考プロセス)が集約ルートになる。Interview(面接)や Evaluation(評価)は ScreeningProcess の内部に属し、外部からは直接触れない。「選考プロセスが進んだ」「面接結果が登録された」といった状態変化は、すべて ScreeningProcess を経由して行われる。

// Evans定義に忠実な集約の構造
class ScreeningProcess {
  private interviews: Interview[];         // 外部から直接参照不可
  private evaluations: Evaluation[];       // 外部から直接参照不可

  addInterview(interview: Interview): void {
    // 集約ルートが不変条件を保護する
    if (this.isCompleted()) {
      throw new Error('完了済みの選考プロセスに面接を追加できない');
    }
    this.interviews.push(interview);
  }
}

Evansはこの概念によって「どのオブジェクトのセットを一貫した状態に保つべきか」という問いに答えた。しかし青本が答えなかった問いが残った。集約はどの程度の大きさに設計するべきか、という問いだ。

Evansの集約設計が残した課題

青本の集約の章を読んだ多くの開発者が直面する問題がある。定義は明快だが、設計指針が抽象的だということだ。

Evansは「集約のサイズは小さく保て」と述べたが、「小さい」の基準は示されなかった。その結果、実務では大きすぎる集約が生まれやすい。例えば採用管理システムで JobPosting(求人票)と ScreeningProcessCandidate を同じ集約に収める設計が生まれることがある。「求人に紐づく選考は求人と一緒に管理したほうが自然だ」という直感からだ。

大きな集約は2つの問題を引き起こす。1つ目はパフォーマンスだ。集約全体をトランザクションの単位として扱うため、集約が大きくなるほどロック範囲が広がる。求人票に100件の選考プロセスが紐づいていると、1件の面接更新で100件全体がロックされる。2つ目は競合だ。異なるユーザーが同じ集約の異なる部分を同時に更新しようとすると、トランザクションが衝突する。

Vernonはこの課題に正面から向き合い、4つの具体的な設計ルールとして整理した。

Vernonの4つの設計ルール

Vernon は赤本(Implementing Domain-Driven Design)の中で、集約の設計に関する4つのルールを提示した。これらのルールはEvansの定義を否定するものではなく、「Evansの定義を実務でどう適用するか」を具体化したものだ。

ルール1: 真の不変条件のみを一貫性境界内で保護する

Vernonの主張の核心はここにある。「集約境界内で保護すべき不変条件は何か」を厳密に問い直すことだ。

不変条件とは、常に真でなければならないビジネスルールだ。「選考プロセスが完了状態のとき、ステージは変更できない」は不変条件だ。これは ScreeningProcess 集約単独で保護できる。一方、「採用枠が埋まったら求人票を自動的にクローズする」はどうか。これは ScreeningProcessJobPosting の両方にまたがる。しかしVernonはこの種のルールを「結果整合性で扱う」と区別した。

ルール1を言い換えれば「1トランザクションで変更する集約は1つだけ」だ。複数の集約を1トランザクションで変更しなければ維持できないルールは、そもそも同一集約内の不変条件ではない可能性が高い。

ルール2: 集約は小さく設計する

Vernonは経験則として「約70%の集約は、ルートエンティティと値オブジェクトのみで構成できる」と述べた。内部にエンティティのコレクションを持つ必要があるケースは、実際には少ないという観察だ。

集約が小さければ、ロードするデータ量が減り、トランザクションのロック範囲が狭まる。変更の衝突が起きにくくなる。

ScreeningProcess の設計を見直すと、Interview を内部エンティティのコレクションとして保持するよりも、Interview を独立した集約として分離できる場合がある。「面接を追加できるのは進行中の選考プロセスのみ」というルールが、本当に同一トランザクション内で検証しなければならないものかどうかを問い直すことがルール2の実践だ。

ルール3: 他の集約はIDで参照する

Vernonは他の集約をオブジェクト参照で持つことを明確に禁止した。代わりにIDで参照する。

採用管理システムの例で言えば、ScreeningProcessJobPosting(求人票)オブジェクトを保持しない。JobPostingId のみを保持する。同様に CandidateId のみを保持する。

このルールには2つの効果がある。1つ目は集約の境界の明確化だ。ScreeningProcess をロードするとき、JobPostingCandidate もロードされない。必要なら別のリポジトリで取得する。2つ目はトランザクション境界の確定だ。IDで参照しているオブジェクトは、同一トランザクションの変更対象に含まれない。

ルール4: 結果整合性にはドメインイベントを使う

ルール1で「複数集約にまたがる整合性は結果整合性で扱う」と述べた。その手段がドメインイベントだ。

ScreeningProcess が最終ステージに進んだとき、JobPosting の採用枠カウントを更新する必要があるとする。Vernonのアプローチでは、ScreeningProcess の変更トランザクション内で JobPosting を直接変更しない。代わりに ScreeningAdvanced イベントをドメインイベントとして発行する。別のサブスクライバーがそのイベントを受け取り、JobPosting を別トランザクションで更新する。

この設計は、一時的な不整合を許容する代わりに、各集約の独立性を保つ。

TypeScriptで実装する

EvansスタイルとVernonスタイルの違いをコードで示す。

// ===== Evansスタイル(大きな集約)=====
class ScreeningProcessEvans {
  private jobPosting: JobPosting;    // オブジェクト参照を持つ
  private candidate: Candidate;      // オブジェクト参照を持つ
  private currentStage: ScreeningStage;
  private interviews: Interview[];   // 内部エンティティのコレクション

  advanceStage(): void {
    if (this.currentStage.isFinal()) throw new Error('Already completed');
    // 同一トランザクション内で JobPosting も更新しようとしてしまう
    if (this.jobPosting.isExpired()) throw new Error('Job posting expired');
    this.currentStage = this.currentStage.next();
  }
}
// ===== Vernonスタイル(小さな集約)=====
abstract class AggregateRoot<T> {
  protected readonly _id: T;
  private _domainEvents: DomainEvent[] = [];

  protected addDomainEvent(event: DomainEvent): void {
    this._domainEvents.push(event);
  }

  pullDomainEvents(): DomainEvent[] {
    const events = [...this._domainEvents];
    this._domainEvents = [];
    return events;
  }
}

class ScreeningProcess extends AggregateRoot<ScreeningProcessId> {
  private readonly jobPostingId: JobPostingId;  // IDで参照(オブジェクト参照なし)
  private readonly candidateId: CandidateId;    // IDで参照(オブジェクト参照なし)
  private currentStage: ScreeningStage;
  private interviews: Interview[];

  private constructor(
    id: ScreeningProcessId,
    jobPostingId: JobPostingId,
    candidateId: CandidateId,
    currentStage: ScreeningStage,
    interviews: Interview[],
  ) {
    super(id);
    this.jobPostingId = jobPostingId;
    this.candidateId = candidateId;
    this.currentStage = currentStage;
    this.interviews = interviews;
  }

  static create(
    id: ScreeningProcessId,
    jobPostingId: JobPostingId,
    candidateId: CandidateId,
  ): ScreeningProcess {
    const process = new ScreeningProcess(
      id,
      jobPostingId,
      candidateId,
      ScreeningStage.initial(),
      [],
    );
    process.addDomainEvent(new ScreeningStarted(id, jobPostingId, candidateId));
    return process;
  }

  advanceStage(): void {
    // 集約内の不変条件のみをチェックする
    if (this.currentStage.isFinal()) throw new Error('Already completed');
    this.currentStage = this.currentStage.next();
    // 境界の外への影響はドメインイベントで通知する
    this.addDomainEvent(new ScreeningAdvanced(this._id, this.currentStage));
  }

  addInterview(interview: Interview): void {
    // 選考プロセス内の不変条件
    if (this.currentStage.isCompleted()) {
      throw new Error('完了済みの選考プロセスに面接を追加できない');
    }
    this.interviews.push(interview);
  }
}
// ===== ドメインイベントのサブスクライバー =====
// ScreeningProcess と JobPosting は別トランザクションで整合する
class ScreeningAdvancedHandler {
  constructor(private readonly jobPostingRepo: JobPostingRepository) {}

  async handle(event: ScreeningAdvanced): Promise<void> {
    if (!event.currentStage.isFinal()) return;
    const jobPosting = await this.jobPostingRepo.findById(event.jobPostingId);
    jobPosting.decrementOpenPositions();
    await this.jobPostingRepo.save(jobPosting);
  }
}

Vernonスタイルでは ScreeningProcessJobPosting を知らない。ScreeningProcess のトランザクションは ScreeningProcess の変更だけを含む。JobPosting の更新は ScreeningAdvanced イベントを受け取った ScreeningAdvancedHandler が別途行う。

EvansとVernonの本質的な違い

2人のアプローチの違いを一言で表すなら「何か」対「どうするか」だ。

Evansは集約という概念を定義した。「関連オブジェクトのクラスター」「集約ルートが唯一の外部アクセスポイント」という概念的な枠組みを作った。青本が登場する以前、この概念自体が存在しなかった。概念を定義したことがEvansの貢献だ。

Vernonは「Evansの定義した概念を実務でどう設計するか」に答えた。4つのルールはすべて設計判断の基準だ。ルール1は「何を集約に含めるか」の判断基準。ルール2は「集約の大きさ」の判断基準。ルール3は「集約間の参照」の実装方針。ルール4は「集約外の整合性」の実現手段。

もう1つの違いは、執筆当時の技術的文脈だ。Evansが青本を書いた2003年当時、分散システムやイベント駆動アーキテクチャは今ほど一般的ではなかった。Vernonが赤本を書いた2013年には、大規模分散システムにおける結果整合性の議論が成熟していた。ルール4がドメインイベントと結果整合性を前提にしているのはその背景による。

実務での集約設計は、Vernonの4ルールを出発点にすることが多い。ルール1でトランザクション境界を確定し、ルール2で不必要に大きくなっていないかを確認し、ルール3でオブジェクト参照が混入していないかを検査し、ルール4で境界外の整合性をドメインイベントで賄う。Evansの定義はこの4ルールの背景にある原則として機能する。


インフォグラフィック

サマリー

章末図:集約設計の対比

graph TD
  subgraph Evans["Evans: 集約の概念定義"]
    AR["集約ルート\n(Aggregate Root)\n唯一の外部アクセスポイント"]
    E1["内部エンティティ"]
    V1["値オブジェクト"]
    V2["値オブジェクト"]
    AR -->|所有| E1
    AR -->|所有| V1
    E1 -->|所有| V2
    OUT["外部オブジェクト"] -->|参照可| AR
    OUT -. "直接参照禁止" .-> E1
  end
graph LR
  subgraph Vernon["Vernon: 4つの設計ルール"]
    R1["ルール1\n真の不変条件のみを\n境界内で保護する\n1トランザクション=1集約"]
    R2["ルール2\n集約は小さく設計する\n約70%はルートと\n値オブジェクトのみ"]
    R3["ルール3\n他の集約はIDで参照\nオブジェクト参照禁止"]
    R4["ルール4\n境界外の整合性は\nドメインイベントで\n結果整合性を使う"]
  end
graph TD
  subgraph SP["ScreeningProcess集約(Vernonスタイル)"]
    SPR["ScreeningProcess\n集約ルート"]
    CS["ScreeningStage\n値オブジェクト"]
    IV["Interview\n内部エンティティ"]
    SPR --> CS
    SPR --> IV
  end

  JPI["JobPostingId\n値オブジェクト(ID参照)"]
  CI["CandidateId\n値オブジェクト(ID参照)"]
  DE["ScreeningAdvanced\nドメインイベント"]

  SPR -- "IDのみ保持" --> JPI
  SPR -- "IDのみ保持" --> CI
  SPR -- "発行" --> DE

  JP["JobPosting集約\n(別トランザクション)"]
  DE -- "非同期で受信" --> JP

  style JPI fill:#f5f5f5,stroke:#aaa
  style CI fill:#f5f5f5,stroke:#aaa
  style JP fill:#e8f4f8,stroke:#5b9bd5