メインコンテンツまでスキップ

集約 — 整合性の境界と集約ルート

集約とは

前章までで、値オブジェクトとエンティティを学びました。本章では、これらをまとめる「集約」という概念を学びます。

集約は以下の特徴を持つ**構造(境界)**です。

  1. エンティティと値オブジェクトをまとめる境界
  2. 集約ルートを持つ:外部からのアクセスポイント
  3. 整合性の境界:集約内の整合性を保証する
  4. トランザクションの単位:原則として1トランザクションで1集約を更新
「1トランザクション = 1集約」は絶対ルールではない

DDDの原典では「1トランザクションで複数集約を更新できるが、それは設計を見直すサイン」とされています。複数集約を同時更新する必要がある場合は、以下を検討してください:

  1. 集約の境界が適切か見直す
  2. ドメインイベントによる結果整合性で対応する
  3. やむを得ない場合のみ、同一トランザクションで複数集約を更新する

本書のトランザクション管理の章で詳しく解説します。

集約ルートとは

集約ルートは、外部から集約にアクセスするための唯一の入口です。

集約ルートの役割

集約ルートは、家の「玄関」のような役割を果たします。

[家の例]

玄関(集約ルート)

- 来客はまず玄関を通る
- 玄関で靴を脱ぐルールを強制
- リビングや寝室には玄関経由でアクセス

[集約の例]

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も一緒に保存される
// → 合計金額も再計算される
// → 整合性が保たれる
集約外のエンティティに直接アクセスしてはいけない理由
  1. ビジネスルールを迂回できてしまう → 不正な状態が作られる
  2. 整合性の境界が不明確 → 何を一緒に保存すべきか分からない
  3. トランザクション境界が曖昧 → データ不整合のリスク
  4. 変更の影響範囲が追跡困難 → メンテナンス性の低下

集約ルートは「番人」として、これらの問題を防ぎます。

集約ルートになる条件

以下の条件を満たすエンティティが集約ルートになります。

条件説明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で最新を参照商品マスタへの参照(最新情報表示)

参考資料

集約についてさらに深く学びたい方は、以下のリソースを参照してください。

次のチャプターでは、エンティティに属さないドメインロジックを扱う「ドメインサービス」について学びます。