本章の方針
集約(Aggregate)は戦術的DDDで 最も強力で、最も失敗しやすい パターンだ。設計を間違えると、パフォーマンス問題・デッドロック・結果整合性への逃げが連鎖する。本章では、集約の本質と Vernon の4ルールを実務視点で再解釈し、「集約を小さく保つ」が2026年の定説 である理由を示す。さらに、集約の限界を超える新しいアプローチ DCB にも触れる。
集約とは何か(再確認)
Evans青本の定義を引用する。
集約とは、データ変更を目的として1つの単位として扱う、関連オブジェクトのクラスターである。
重要なキーワードは3つ。
- クラスター:複数のEntity/VOの集まり
- 1つの単位:1トランザクションで一貫性を保つ
- データ変更:変更操作の境界であり、読み取りの境界ではない
集約の入口となる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つある。
- 諦めて補償トランザクションで戻す
- 集約を統合して同一トランザクション内にする
- 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=C1 と student_id=S1 の両方のタグを持つイベントと整合する必要がある」と宣言すれば、その交わりだけがトランザクション境界になる。
本記事では深入りしない。興味がある読者は姉妹記事 DCB(Dynamic Consistency Boundary) を参照してほしい。Ch.9 でも2026年の論争として触れる。
本章のまとめ
・集約は「1トランザクションで一貫性を保つ単位」
・2026年の定説:集約は小さく保つ(1Entity+付随VOが基本形)
・他の集約への参照はIDで。オブジェクト参照は持たない
・集約をまたぐルールは結果整合性が基本。ただし逃げの言葉にしない
・集約では表現しきれないルールには DCB という新しい選択肢がある
次章では、集約の外側── Services と Repository を扱う。「ロジックはどこに置くべきか」問題の深淵に入る。