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

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

集約とは​

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

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

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

Eric Evans は、集約の境界がこうした設計判断をうまく導けていないならモデルを見直すべきだとしています。Vaughn Vernon はさらに「1 トランザクションで 1 集約」を絶対ルールではなく経験則 (rule of thumb) と位置づけました。複数集約を同時更新する必要がある場合は、以下を検討してください:

  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
$orderLineRepository->delete($orderLine); // 整合性が壊れるリスク
// → 問題:Orderの状態(DRAFT/CONFIRMED)をチェックできない
// → 問題:確定済み注文の明細を勝手に削除できてしまう

なぜ集約ルートを経由する必要があるのか​

直接アクセスを許すと、以下の問題が発生します。

問題1:ビジネスルールの迂回​

// NGパターン:OrderLineに直接アクセス
class RemoveOrderLineUseCase
{
public function execute(OrderLineId $lineId): void
{
// OrderLineを直接取得
$orderLine = $this->orderLineRepository->findById($lineId);
$this->orderLineRepository->delete($orderLine);
}
}
// 問題:親のOrder状態(確定済みかどうか)をチェックしていない!
// 問題:確定済み注文の明細を削除できてしまう!
// OKパターン:Order経由でアクセス
use App\Domain\Order\Exception\InvalidOrderStateException;

class RemoveOrderLineUseCase
{
public function execute(OrderId $orderId, OrderLineId $lineId): void
{
// Orderを取得(集約ルート経由)
$order = $this->orderRepository->findById($orderId);
$order->removeItem($lineId);
$this->orderRepository->save($order);
}
}

class Order
{
public function removeItem(OrderLineId $lineId): void
{
// ビジネスルール:下書き状態でのみ変更可能
if (!$this->status->isDraft()) {
throw new InvalidOrderStateException('確定後は明細を変更できません');
}

// 該当する明細を取り除く(OrderLineは不変なので作り直さない)
$this->orderLines = array_values(
array_filter(
$this->orderLines,
fn (OrderLine $line) => !$line->id()->equals($lineId)
)
);
}
}

問題2:整合性の破壊​

// NGパターン:OrderとOrderLineを別々に保存
$order = $orderRepository->findById($orderId);
$orderLine = $orderLineRepository->findById($lineId);

$orderLineRepository->delete($orderLine); // OrderLineだけ削除

// 問題:Orderの合計金額が更新されない!
// 問題:トランザクションの境界が不明確

// OKパターン:集約ルート(Order)を保存
$order = $orderRepository->findById($orderId);
$order->updateLineQuantity($lineId, 10);
$orderRepository->save($order); // Order全体を保存
// → OrderLineも一緒に保存される
// → 合計金額も再計算される
// → 整合性が保たれる
集約内部のエンティティに集約ルートを経由せずアクセスしてはいけない理由
  1. ビジネスルールを迂回できてしまう → 不正な状態が作られる
  2. 整合性の境界が不明確 → 何を一緒に保存すべきか分からない
  3. トランザクション境界が曖昧 → データ不整合のリスク
  4. 変更の影響範囲が追跡困難 → メンテナンス性の低下

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

集約ルートになる条件​

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

条件説明Order集約での例
外部からのアクセス起点他のオブジェクトへはこのオブジェクト経由注文明細は注文を経由して操作
ライフサイクルの管理者内部オブジェクトの作成・削除を管理注文削除時に明細も削除
整合性の責任者ビジネスルールを強制する責任「確定後は追加不可」を管理

集約の境界を決める基準​

以下の基準で、同じ集約に入れるか、別の集約にするかを判断します。

基準同じ集約別の集約
ライフサイクル一緒に作成・削除独立して存在
整合性の範囲常に整合性が必要結果整合性で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:注文を削除してもユーザーは削除されない

結論:別の集約にする
→ 誰の注文かはordersテーブルのuser_idが持つ(第16章の認可の関心)
→ ユーザー情報が必要ならUserRepositoryで取得

境界の置き方を検算する 3 つの観点​

上の 4 つの問いは、同じ集約に入れるかどうかを決めるものです。決めた境界が妥当かを確かめる観点が、ほかに 3 つあります。ddd-crew の Aggregate Design Canvas が、集約 1 つを書き出すときの項目として挙げているものです (第 6 章で触れた用紙)。

観点問い外したときに起きること
ずれを直す担当境界を緩めるなら、食い違いを誰が直すかずれたまま誰も気づかない
競合の起きやすさ1 つのインスタンスを同時に更新しにいく処理がどれだけあるか更新が頻繁に弾かれて業務が止まる
大きさ1 回の読み書きでどれだけの範囲を出し入れするか1 件の更新に無関係な行まで付いてくる

1 つ目は、結果整合性を選ぶことの代償です。別の集約に分けた時点で、2 つが食い違う瞬間ができます。その瞬間を許すかどうかだけでなく、食い違ったまま残ったものを誰が直すかまで決めて、はじめて境界を決めたことになります。この「直す側の処理」を、上記の用紙は corrective policies と呼んでいます。選ぶこと自体は次節の「即時整合性 vs 結果整合性」が、直しそこねたときに何が起きるかは第 12 章の「結果整合性の注意点」が扱います。

2 つ目は、境界を大きく取ったときに現れます。1 つの集約にまとめるほど、同じインスタンスへの更新が 1 箇所に集まります。人気商品の在庫を 1 つの集約にすると、注文が重なるたびに更新が競合します。検出の仕組みは第 19 章の楽観的ロックですが、そもそも競合しにくい境界にできないかを先に考えます。

3 つ目は、集約を必ずまとめて出し入れすることから来ます。集約ルート経由でしか触れない以上、明細が 1,000 行ある注文は、1 行だけ直したいときも 1,000 行を読むことになります。境界の大きさは、そのまま 1 回の操作の重さです。なお、この用紙は大きさを「1 インスタンスに積もるイベントの数」で測りますが、それは集約をイベントの列として保存する設計を前提にした指標です。

即時整合性 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();
$orderRepository->save($order); // トランザクション1

// 在庫減少(Inventory集約)は後で実行してもOK
// ドメインイベントやキューで非同期処理
event(new OrderConfirmed(
$order->id(),
array_map(fn (OrderLine $line) => $line->id(), $order->orderLines()),
$order->totalAmount(),
new DateTimeImmutable(),
));

// → 最終的に在庫が減っていればOK
// → 数秒のズレは許容される

即時整合性 vs 結果整合性の判断基準​

判断項目即時整合性結果整合性
ビジネス要件データの不一致が許されない短時間の不一致は許容される
パフォーマンストランザクションが重くなる非同期処理で高速化
複雑さシンプル(同一トランザクション)複雑(イベント処理が必要)
例注文合計と明細の合計注文後の在庫減少、ポイント付与

結果整合性の実装例​

// 1. ドメインイベントを発行
class Order
{
public function confirm(): void
{
// ... 状態を確定

// ドメインイベントを記録
$this->recordEvent(new OrderConfirmed(
$this->id,
array_map(fn (OrderLine $line) => $line->id(), $this->orderLines),
$this->totalAmount(),
new DateTimeImmutable(),
));
}
}

// 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()
);
}
}
}

結果整合性を実現するための ドメインイベント は、第12章「ドメインイベント」で詳しく解説します。

集約間の参照​

別の集約を参照する場合は、IDのみを保持します。

なぜIDのみ?

// NG: Product全体を保持
class OrderLine
{
private Product $product; // Product集約全体を保持
}
// 問題:
// - Productが変更されるとOrderLineにも影響
// - どちらの集約が「正しい」状態かわからなくなる
// OK: ProductIdのみを保持
class OrderLine
{
private ProductId $productId; // IDのみ保持
}
// メリット:
// - 集約間の結合が疎
// - 各集約が独立して変更可能

スナップショット​

注文時点の配送先住所など、ある時点の状態をコピーして保存したものをスナップショットと呼びます。

[タイムライン]

1. 注文時
ユーザー住所:東京都渋谷区
注文の配送先:東京都渋谷区 ← スナップショットとして保存

2. ユーザーが引っ越し
ユーザー住所:大阪府梅田 ← 変更
注文の配送先:東京都渋谷区 ← 変わらない

配送先は? → 東京都渋谷区(注文時点の住所)
保存方法説明例
スナップショット時点の状態をコピー注文時の配送先、注文時の商品価格
参照IDで最新を参照商品マスタへの参照(最新情報表示)

参考資料​

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

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