目次を表示する

戦術的DDD実践ガイド 2026

集約(Aggregate)── 一貫性境界の設計と実務の痛み

本章の方針

集約(Aggregate)は戦術的DDDで 最も強力で、最も失敗しやすい パターンだ。設計を間違えると、パフォーマンス問題・デッドロック・結果整合性への逃げが連鎖する。本章では、集約の本質と Vernon の4ルールを実務視点で再解釈し、「集約を小さく保つ」が2026年の定説 である理由を示す。さらに、集約の限界を超える新しいアプローチ DCB にも触れる。


集約とは何か(再確認)

Evans青本の定義を引用する。

集約とは、データ変更を目的として1つの単位として扱う、関連オブジェクトのクラスターである。

重要なキーワードは3つ。

  1. クラスター:複数のEntity/VOの集まり
  2. 1つの単位:1トランザクションで一貫性を保つ
  3. データ変更:変更操作の境界であり、読み取りの境界ではない

集約の入口となるEntityが 集約ルート(Aggregate Root) で、外部はここにしかアクセスできない。

// [集約] Subscription集約
class Subscription {  // ← 集約ルート
  private _usageRecords: UsageRecord[]  // ← 内部Entity(外部から直接触らせない)
  private _currentPeriod: BillingPeriod  // ← 内部VO

  // 外部操作は集約ルートを経由する
  recordUsage(seatCount: number, at: Date): void {
    if (this._status !== 'active') throw new Error('Inactive')
    this._usageRecords.push(UsageRecord.of(seatCount, at))
  }
}

集約の目的は「一貫性境界」

集約の設計目的はただ1つ、「一貫性を保つ単位を明示する」 ことだ。

SaaS課金管理で考える。

ルール1: 契約の seatCount(シート数)は、契約の plan.maxSeats を超えてはならない
ルール2: 顧客の全契約の合計月額は、顧客の billing_limit を超えてはならない

ルール1 は Subscription 集約で保護できる(plan.maxSeats を知っていれば集約内で検証できる)。ルール2 は Customer 集約と Subscription 集約をまたぐ── この種のルールをどう扱うかが、集約設計の核心 だ。

答えは3つある。

アプローチ内容本記事の立場
1. 集約をまたぐトランザクション1つのトランザクションで両方更新禁じ手。スケールしない
2. 大きな集約に統合Customer配下にSubscriptionを置く多くの場合で過剰
3. 結果整合性 + 補償トランザクション一時的に超過しうる状態を許す2026年の定説
4. DCB で動的境界を引く実行時にタグで境界を決める新しい選択肢(Ch.9参照)

Vernon の4ルールを実務で再解釈する

Vernon赤本が示した4つの集約設計ルールは、いまも有効だ。ただし実務で使うには 解釈が必要 だ。

ルール1:真の不変条件だけを境界内で守る

原典:集約境界内で保護すべき不変条件は何か、を厳密に問う
実務:「一時的に違反してもビジネス的に問題ないルール」は
      集約外に出す

「顧客の月額合計が上限を超えない」というルールを考える。本当に リアルタイムで1秒も違反できない のか?実務では「締日の夜間バッチで検知して翌日是正」で十分なケースが多い。本当にトランザクション内で守る必要があるか を問い直すのが第一歩。

ルール2:小さな集約を設計する

原典:集約を小さく保て
実務:「可能な限りEntity 1つだけの集約」を目指せ

2026年時点での定説は 「集約は1エンティティ+付随VO」を基本形とする だ。複数Entityを含む集約は、必ず「本当に1トランザクションで更新するのか」を問う。

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

原典:他の集約への参照はID(ValueObject)で持つ
実務:オブジェクト参照で持つとORMが「Cascade Load」でDBを破壊しに来る

これは パフォーマンスに直結する 重要ルールだ。

// ❌ オブジェクト参照で持つ
class Subscription {
  private customer: Customer  // ← Customerのロードが連鎖する
}

// ✅ IDで参照する
class Subscription {
  readonly customerId: CustomerId  // ← IDだけ持つ
}

後者なら Customer の情報が必要なときに 明示的にRepositoryから取得 する。暗黙の読み込みを排除できる。

ルール4:境界外では結果整合性を使う

原典:境界をまたぐ整合性は Eventual Consistency で
実務:「結果整合性」という言葉で逃げる前に、本当に結果整合でよいかを問う

これが最も難しいルールだ。「結果整合性」という魔法の言葉で複雑さを隠蔽する誘惑に気をつけたい。

結果整合性が妥当なケース:
  ・通知送信(多少遅れても問題ない)
  ・レポート・分析(夜間バッチで十分)
  ・監査ログ(イベントで追えれば良い)

結果整合性だと苦しいケース:
  ・在庫の超過販売(ビジネス的にクレームになる)
  ・支払いと契約状態の不整合(UI上で「支払済なのに期限切れ表示」)
  ・同時予約(物理的に同時に座席を取らせたくない)

後者のケースでは、DCB という新しいアプローチが検討価値を持つ(本章末と Ch.9 で触れる)。


集約サイズの指標:「何件のEntityを同時ロードするか」

集約が大きすぎるかを判定する実務的な指標がある。

集約ロード時にN件のEntityを取得する集約は、Nが大きいほど破綻に近い。

N = 1〜10       :安全
N = 10〜100     :設計見直しの余地あり
N > 100         :ほぼ確実に境界設計を間違えている
N が可変で上限なし:100%間違っている

SaaS課金管理で失敗例を見る。

// ❌ 間違った集約設計(実際に見た例)
class Customer {
  private subscriptions: Subscription[]  // 顧客の全契約
  private invoices: Invoice[]            // 顧客の全請求書
  private usageRecords: UsageRecord[]    // 顧客の全使用履歴(数千件)
  // ...
}

// Customerをロードするだけで全履歴を取得してしまう
const customer = await customerRepo.findById(id)  // ← 数秒かかる

この Customer は、「顧客に関係するもの全部」を集約してしまった 典型例だ。正しい設計はこうなる。

// ✅ 小さな集約に分解
class Customer { /* プロフィール情報のみ */ }

class Subscription { /* 1契約の状態 */ }  // 別集約
class Invoice { /* 1請求書の状態 */ }      // 別集約
class UsageRecord { /* 1日分の使用記録 */ } // 別集約

Subscription集約 vs Invoice集約:境界の決め方

通し事例で「どこに境界を引くか」の実例を示す。以下の2つの設計候補がある。

候補A:Subscription と Invoice を同じ集約にする

class Subscription {
  private _invoices: Invoice[]

  issueInvoice(period: BillingPeriod): Invoice {
    const invoice = Invoice.create(this.customerId, this.planId, period)
    this._invoices.push(invoice)
    return invoice
  }
}

メリット:「契約に紐づく請求書」を一貫して更新できる デメリット:契約を何年も続けると請求書が数十〜数百件に。ロードが重い

候補B:Subscription と Invoice を別集約にする

class Subscription {
  issueInvoice(period: BillingPeriod): InvoiceIssued {  // イベントを返す
    return new InvoiceIssued(this.id, this.customerId, this.planId, period)
  }
}

// Invoice は別集約として独立
class Invoice {
  static fromIssuedEvent(event: InvoiceIssued): Invoice {
    return new Invoice(...)
  }
}

メリット:各集約が小さく保たれる、パフォーマンスが安定 デメリット:集約間の整合性は結果整合に依存(契約が変更されてから請求書が更新されるまでに遅延がある)

2026年の推奨:候補B

筆者の立場は明確に 候補B だ。Subscriptionが長期間継続するドメインでは、候補Aは必ず破綻する。「結果整合性の遅延」を許容できるようにドメインを設計する(Ch.7 のDomain Eventsで扱う)。


集約の肯定的な体験

体験1:ビジネスルールの集約場所ができた

かつて、「プラン変更時に seat 数が maxSeats を超えないか」のチェックがコントローラー・サービス・バッチの3箇所に散らばっていた。Subscription集約に upgrade() メソッドを集約した結果、ルールの修正がドメイン層1箇所で済む ようになった。

体験2:テストが書きやすくなった

集約は「ドメインロジックのかたまり」なので、DB不要でテストできる

test('キャンセル済みの契約はアップグレードできない', () => {
  const sub = Subscription.create(customerId, basicPlanId, period)
  sub.cancel(new Date())

  expect(() => sub.upgrade(proPlanId)).toThrow()
})

ドメインロジックがコードの隅々に散らばっていた時代と比べて、単体テストのカバレッジが劇的に向上 した。


集約の否定的な体験

体験1:境界選びの会議が終わらない

「Subscription と Invoice を同じ集約にすべきか」といった議論に、プロジェクト期間を通じて何十時間も費やす ことになった。境界の選択は正解が一意ではなく、ビジネスドメインの成長とともに変化するため、「一度決めたら終わり」にならない。「小さく始めて、必要になったら統合する」 ほうが結果的に早い、というのが筆者の学びだ。

体験2:結果整合性の複雑性

候補B(小さな集約+結果整合)は理論的に美しいが、実装の複雑性は高い。

・イベントの Outbox パターンが必要
・イベント消費側の冪等性設計が必要
・「契約は変わったが請求書がまだ古い」状態の UI 表現
・補償トランザクションの設計
・デバッグ時に「今どのイベントまで処理されたか」を追う必要

小さな集約は「設計の美しさ」と引き換えに、運用の複雑性 を支払う。ここは正直に認めるべきだ。

体験3:過小な集約でビジネスルール違反が潜む

逆に集約を小さくしすぎた結果、「2つの小さな集約を同時更新しないと守れないルール」が増え、結果整合性の遅延中にビジネスルール違反が発生する ことがあった。例:「プランをダウングレードしたのに、旧プランの料金で請求書が発行された」。

この問題への2026年時点の回答は3つある。

  1. 諦めて補償トランザクションで戻す
  2. 集約を統合して同一トランザクション内にする
  3. DCB(Dynamic Consistency Boundary)を導入する

集約の限界:DCBへの扉

前述の「ルール1: 1講座に最大30名」「ルール2: 1人に最大10講座」のように、2つの軸にまたがる一貫性ルール は、どちらの集約に置いても他方が保護できない。

DCB(Dynamic Consistency Boundary) は、Sara Pellegrini が2023年に “Kill Aggregate!” と題したブログ記事で提唱し、以降ヨーロッパのDDDコミュニティで議論が広がっているアプローチだ。

DCBの核心は、「一貫性境界を集約という静的な構造ではなく、イベントに付与されたタグの交わりとして動的に決める」 ことだ。「この操作は course_id=C1student_id=S1 の両方のタグを持つイベントと整合する必要がある」と宣言すれば、その交わりだけがトランザクション境界になる。

本記事では深入りしない。興味がある読者は姉妹記事 DCB(Dynamic Consistency Boundary) を参照してほしい。Ch.9 でも2026年の論争として触れる。


本章のまとめ

・集約は「1トランザクションで一貫性を保つ単位」
・2026年の定説:集約は小さく保つ(1Entity+付随VOが基本形)
・他の集約への参照はIDで。オブジェクト参照は持たない
・集約をまたぐルールは結果整合性が基本。ただし逃げの言葉にしない
・集約では表現しきれないルールには DCB という新しい選択肢がある

次章では、集約の外側── Services と Repository を扱う。「ロジックはどこに置くべきか」問題の深淵に入る。