集約 — 整合性の境界と集約ルート
集約とは
前章までで、値オブジェクトとエンティティを学びました。本章では、これらをまとめる「集約」という概念を学びます。
集約は以下の特徴を持つ**構造(境界)**です。
- エンティティと値オブジェクトをまとめる境界
- 集約ルートを持つ:外部からのアクセスポイント
- 整合性の境界:集約内の整合性を保証する
- トランザクションの単位:原則として1トランザクションで1集約を更新
「1トランザクション = 1集約」は絶対ルールではない
DDDの原典では「1トランザクションで複数集約を更新できるが、それは設計を見直すサイン」とされています。複数集約を同時更新する必要がある場合は、以下を検討してください:
- 集約の境界が適切か見直す
- ドメインイベントによる結果整合性で対応する
- やむを得ない場合のみ、同一トランザクションで複数集約を更新する
本書のトランザクション管理の章で詳しく解説します。
集約ルートとは
集約ルートは、外部から集約にアクセスするための唯一の入口です。
集約ルートの役割
集約ルートは、家の「玄関」のような役割を果たします。
[家の例]
玄関(集約ルート)
↓
- 来客はまず玄関を通る
- 玄関で靴を脱ぐルールを強制
- リビングや寝室には玄関経由でアクセス
[集約の例]
Order(集約ルート)
↓
- 外部はOrderを経由してアクセス
- Orderがビジネスルールを強制
- OrderLineへはOrder経由でアクセス
良い例と悪い例
// OK: 集約ルート(Order)経由でアクセス
$order = $orderRepository->findById($orderId);
$order->addItem($productId, $quantity, $unitPrice);
$orderRepository->save($order);
// → Orderが「確定後は追加不可」などのルールを強制
// NG: OrderLineに直接アクセス
$orderLine = $orderLineRepository->findById($lineId); // これはNG
$orderLine->updateQuantity(5); // 整合性が壊れるリスク
// → 問題:Orderの状態(DRAFT/CONFIRMED)をチェックできない
// → 問題:確定済み注文の明細を勝手に変更できてしまう
なぜ集約ルート経由でないとダメなのか
直接アクセスを許すと、以下の問題が発生します。
問題1:ビジネスルールの迂回
// NGパターン:OrderLineに直接アクセス
class UpdateOrderLineQuantityUseCase
{
public function execute(OrderLineId $lineId, int $newQuantity): void
{
// OrderLineを直接取得
$orderLine = $this->orderLineRepository->findById($lineId);
$orderLine->updateQuantity($newQuantity);
$this->orderLineRepository->save($orderLine);
}
}
// 問題:親のOrder状態(確定済みかどうか)をチェックしていない!
// 問題:確定済み注文の明細を変更できてしまう!
// OKパターン:Order経由でアクセス
class UpdateOrderLineQuantityUseCase
{
public function execute(OrderId $orderId, OrderLineId $lineId, int $newQuantity): void
{
// Orderを取得(集約ルート経由)
$order = $this->orderRepository->findById($orderId);
$order->updateLineQuantity($lineId, $newQuantity);
$this->orderRepository->save($order);
}
}
class Order
{
public function updateLineQuantity(OrderLineId $lineId, int $newQuantity): void
{
// ビジネスルール:下書き状態でのみ変更可能
if (!$this->status->isDraft()) {
throw new DomainException('確定後は明細を変更できません');
}
// 明細を探して更新
foreach ($this->orderLines as $line) {
if ($line->id()->equals($lineId)) {
$line->updateQuantity($newQuantity);
return;
}
}
}
}
問題2:整合性の破壊
// NGパターン:OrderとOrderLineを別々に保存
$order = $orderRepository->findById($orderId);
$orderLine = $orderLineRepository->findById($lineId);
$orderLine->updateQuantity(10);
$orderLineRepository->save($orderLine); // OrderLineだけ保存
// 問題:Orderの合計金額が更新されない!
// 問題:トランザクションの境界が不明確
// OKパターン:集約ルート(Order)を保存
$order = $orderRepository->findById($orderId);
$order->updateLineQuantity($lineId, 10);
$orderRepository->save($order); // Order全体を保存
// → OrderLineも一緒に保存される
// → 合計金額も再計算される
// → 整合性が保たれる
集約外のエンティティに直接アクセスしてはいけない理由
- ビジネスルールを迂回できてしまう → 不正な状態が作られる
- 整合性の境界が不明確 → 何を一緒に保存すべきか分からない
- トランザクション境界が曖昧 → データ不整合のリスク
- 変更の影響範囲が追跡困難 → メンテナンス性の低下
集約ルートは「番人」として、これらの問題を防ぎます。
集約ルートになる条件
以下の条件を満たすエンティティが集約ルートになります。
| 条件 | 説明 | Order集約での例 |
|---|---|---|
| 外部からのアクセス起点 | 他のオブジェクトへはこのオブジェクト経由 | 注文明細は注文を経由して操作 |
| ライフサイクルの管理者 | 内部オブジェクトの作成・削除を管理 | 注文削除時に明細も削除 |
| 整合性の責任者 | ビジネスルールを強制する責任 | 「確定後は追加不可」を管理 |
なぜ集約ルート経由でアクセスするのか
集約ルートが整合性を保証するからです。
class Order
{
public function addItem(ProductId $productId, int $quantity, Money $unitPrice): void
{
// ビジネスルール:確定後は商品を追加できない
if ($this->status === OrderStatus::CONFIRMED) {
throw new DomainException('確定後は商品を追加できません');
}
$this->orderLines[] = new OrderLine($lineId, $productId, $quantity, $unitPrice);
}
}
もしOrderLineに直接アクセスできてしまうと、このビジネスルールを迂回できてしまいます。
集約の境界を決める基準
以下の基準で、同じ集約に入れるか、別の集約にするかを判断します。
| 基準 | 同じ集約 | 別の集約 |
|---|---|---|
| ライフサイクル | 一緒に作成・削除 | 独立して存在 |
| 整合性の範囲 | 常に一貫性が必要 | 結果整合性でOK |
| トランザクション | 同時に更新 | 別々に更新可能 |
| 参照元 | この集約からのみ | 複数箇所から参照 |
具体例で理解する集約の境界
例1:OrderとOrderLineは同じ集約
質問1: ライフサイクルが一緒?
→ YES:注文を削除したら明細も削除される
質問2: 常に整合性が必要?
→ YES:注文の合計金額と明細の合計は常に一致すべき
質問3: 同じトランザクションで更新?
→ YES:明細を追加したら注文の合計金額も同時に更新
質問4: この集約からのみ参照?
→ YES:OrderLineはOrderからのみアクセスされる
結論:同じ集約に入れる
例2:OrderとProductは別の集約
質問1: ライフサイクルが一緒?
→ NO:注文を削除しても商品は削除されない
結論:別の集約にする
→ OrderはProductIdのみを保持
→ 商品情報が必要ならProductRepositoryで取得
例3:OrderとUserは別の集約
質問1: ライフサイクルが一緒?
→ NO:注文を削除してもユーザーは削除されない
結論:別の集約にする
→ OrderはUserIdのみを保持
→ ユーザー情報が必要ならUserRepositoryで取得
即時整合性 vs 結果整合性
集約の境界を決める重要な概念が整合性のタイミングです。
即時整合性(Immediate Consistency)
同じトランザクション内で即座に整合性を保つ必要がある場合。
// 例:注文の合計金額と明細の合計
$order->addItem($productId, 2, Money::jpy(1000));
// → 即座にorder.totalAmountが2000円に更新される必要がある
// トランザクション内で即座に整合
DB::transaction(function () use ($order) {
// OrderとOrderLineを同時に保存
$this->orderRepository->save($order);
});
// → コミット時点で整合性が保証される
結果整合性(Eventual Consistency)
最終的に整合性が取れればOKな場合。同一トランザクションである必要はない。
// 例:注文確定後の在庫減少
// 注文確定(Order集約)
$order->confirm();
$this->orderRepository->save($order); // トランザクション1
// 在庫減少(Inventory集約)は後で実行してもOK
// ドメインイベントやキューで非同期処理
event(new OrderConfirmed($order->id()));
// → 最終的に在庫が減っていればOK
// → 数秒のズレは許容される
即時整合性 vs 結果整合性の判断基準
| 判断項目 | 即時整合性 | 結果整合性 |
|---|---|---|
| ビジネス要件 | データの不一致が許されない | 短時間の不一致は許容される |
| パフォーマンス | トランザクションが重くなる | 非同期処理で高速化 |
| 複雑さ | シンプル(同一トランザクション) | 複雑(イベント処理が必要) |
| 例 | 注文合計と明細の合計 | 注文後の在庫減少、ポイント付与 |
結果整合性の実装例
// 1. ドメインイベントを発行
class Order
{
public function confirm(): void
{
// ... 状態を確定
// ドメインイベントを記録
$this->recordEvent(new OrderConfirmed($this->id));
}
}
// 2. イベントハンドラで非同期処理
class OrderConfirmedHandler
{
public function handle(OrderConfirmed $event): void
{
// 別のトランザクションで在庫を減らす
$order = $this->orderRepository->findById($event->orderId);
foreach ($order->orderLines() as $line) {
// Inventory集約を更新(別トランザクション)
$this->inventoryService->decrease(
$line->productId(),
$line->quantity()
);
}
}
}
結果整合性(Eventual Consistency)とは
- 即座に一貫性を保つ必要はないが、最終的には一貫した状態になるという整合性モデル
- 例:注文確定後に在庫を減らす処理は、同一トランザクションでなくても、最終的に在庫が正しく減っていれば問題ない場合がある
- メリット:パフォーマンス向上、スケーラビリティ向上
- デメリット:実装が複雑、一時的な不整合が発生
使い分けのポイント
- ビジネス要件として「即座に整合性が必要」→ 同じ集約に
- ビジネス要件として「最終的に整合性が取れればOK」→ 別の集約に
結果整合性を実現するための ドメインイベント は、第9章「ドメインイベント」で詳しく解説します。
集約間の参照
別の集約を参照する場合は、IDのみを保持します。
なぜIDのみ?
// NG: Product全体を保持
class OrderLine
{
private Product $product; // Product集約全体を保持
}
// 問題:
// - Productが変更されるとOrderLineにも影響
// - どちらの集約が「正しい」状態かわからなくなる
// OK: ProductIdのみを保持
class OrderLine
{
private ProductId $productId; // IDのみ保持
}
// メリット:
// - 集約間の結合が疎
// - 各集約が独立して変更可能
スナップショット
注文時点の配送先住所など、ある時点の状態をコピーして保存したものをスナップショットと呼びます。
[タイムライン]
1. 注文時
ユーザー住所:東京都渋谷区
注文の配送先:東京都渋谷区 ← スナップショットとして保存
2. ユーザーが引っ越し
ユーザー住所:大阪府梅田 ← 変更
注文の配送先:東京都渋谷区 ← 変わらない
配送先は? → 東京都渋谷区(注文時点の住所)
| 保存方法 | 説明 | 例 |
|---|---|---|
| スナップショット | 時点の状態をコピー | 注文時の配送先、注文時の商品価格 |
| 参照 | IDで最新を参照 | 商品マスタへの参照(最新情報表示) |
参考資料
集約についてさらに深く学びたい方は、以下のリソースを参照してください。
- DDD_Aggregate - Martin Fowler:集約の概念を分かりやすく解説
次のチャプターでは、エンティティに属さないドメインロジックを扱う「ドメインサービス」について学びます。