cross-aggregate-constraints
集約間の制約チェック
集約間の制約に直面したら、まず要件を疑い、次に技術的制約を理解し、最後に覚悟を決める。
典型的な問題
「集約Aのユースケースで集約Bの状態を確認したい」という要求が発生する。
例: 「一つでも商品が紐づいているブランドは削除できない」
Brand集約(削除したい)←── 制約チェック ──→ Product集約(紐づき確認)
この種の要求に対して、安易に技術的解決策に飛びつくべきではない。
ステップ1: 要件を疑う
技術の前に、ビジネス要件自体を問い直す。
問い直しのフレームワーク
その制約は本当に必要か?
↓
ライフサイクル全体で考えるとどうなるか?
↓
実運用では実質的にどういう制約になるか?
↓
複雑な実装に見合うビジネス価値があるか?
具体例: ブランド削除制約
「一つでも商品が紐づいているブランドは削除できない」を問い直す:
| 観点 | 分析 |
|---|---|
| 初期状態 | ブランド登録時は商品ゼロ。削除可能 |
| 運用中 | ほとんどのブランドに何かしらの商品が紐づく |
| 実質的な制約 | 「ブランドは削除できない」と同義になる |
| 結論 | 複雑な制約チェック機構を作る意味があるか再検討すべき |
要件緩和の選択肢
| 緩和案 | 説明 |
|---|---|
| 論理削除 | ブランドを「非アクティブ」にする。商品紐づきチェック不要 |
| 制約の廃止 | ブランド削除自体を禁止し、制約チェックを不要にする |
| 条件の変更 | 「N日以上商品が紐づいていないブランドのみ削除可」等 |
| 許容 | 紐づき先のないゴミデータの存在を受け入れる |
ステップ2: そもそもモデリングの問題ではないか
集約間の制約が必要に見える場合、集約の境界が間違っている可能性がある。
トランザクションは複数のエンティティにまたがりますか? この質問の答えがイエスならば、間違った集約ルートを持っていると言えるでしょう。 --- Lightbend Academy
関係性に基づく判断フロー
2つの「集約」を常に一緒に操作する必要がある場合:
A : B の関係は?
├─ 1:1 → 同一集約に統合を検討
│ 例: Task(report: TaskReport)
│ → 1トランザクションで不変条件を維持できる
│
├─ 1:N(少量) → 要件調整で小規模化できないか検討
│ → 可能なら同一集約に統合
│
├─ 1:N(大量) → 別集約 + 結果整合性
│ → ドメインイベントで連携
│
└─ Bは純粋なクエリ要件か?
├─ YES → CQRSのリードモデル(プロジェクション)で対応
│ → Bは集約ではなくビューとして構築
└─ NO → ドメイン知識を持つ → 独立集約 + 結果整合性
具体例: Task と TaskReport
「TaskReportの作成を忘れるとまずい」ならば、両者は独立できない可能性が高い。
// 1:1なら同一集約に統合
class Task private constructor(
val id: TaskId,
val name: TaskName,
val report: TaskReport // 集約内に含める
) {
companion object {
fun create(name: TaskName): Task {
val id = TaskId.generate()
return Task(id, name, TaskReport.create(id))
// TaskReport作成忘れが構造的に不可能になる
}
}
}
TaskReportが純粋なクエリ要件なら、CQRSのリードモデルとして構築すべき。集約ではなくプロジェクションで対応することで、制約チェック自体が不要になる。
ステップ3: Sagaの誤用を避ける
集約間の制約チェックにSagaを使おうとするのは、よくある誤用パターンである。
Sagaの本来の目的
Sagaは複数の操作を順番に実行し、失敗時に補償するためのパターンである。
Sagaの正しい適用:
注文受付 → 在庫引当 → 決済処理 → 配送手配
(どこかで失敗したら補償トランザクションで巻き戻す)
Sagaの誤用:
「ブランドに商品が紐づいているか確認して、紐づいていたら削除を拒否する」
→ これは一連の操作ではなく、ただの制約チェック。Sagaの出番ではない
誤用パターンの検出基準
| 観点 | Sagaが適切 | Sagaが不適切 |
|---|---|---|
| 目的 | 複数ステップの分散トランザクション管理 | 単純なデータ存在チェック |
| 性質 | 長時間にわたる複数操作の協調 | 同期的な制約確認 |
| 失敗時 | 補償トランザクションで巻き戻し | 操作の拒否 |
ステップ4: CQRS/ESの技術的制約を理解する
大原則: コマンド側はコマンド側だけで解決する
コマンドがクエリ側のリードモデルに依存してはならない。コマンド側はコマンド側だけで解決できないと設計が破綻する。
❌ 禁止: コマンド側 → リードモデル(クエリ側)を参照して判断
✅ 許可: コマンド側 → 他の集約にコマンド/メッセージで問い合わせ
なぜリードモデルに依存してはいけないか:
| 理由 | 説明 |
|---|---|
| イベントの非同期性 | イベントストアへの書き込みとリードモデルへの反映の間にラグが発生する |
| レース条件 | リードモデル参照→コマンド実行の間に状態が変わる可能性がある |
| 責任分離違反 | コマンドモデル自体の検証ロジックが曖昧になる |
| 結果整合性との矛盾 | 強い整合性が必要な操作で結果整合性に依存する危険性 |
アンチパターン: リードモデルで事前チェック
// ❌ コマンド側がクエリ側に依存している
class CreateProductUseCase(
private val productRepository: ProductRepository,
private val brandReadModelRepository: BrandReadModelRepository, // リードモデルへの依存
) {
fun execute(brandId: BrandId, productName: String) {
// ① リードモデルでブランド存在チェック
val brandExists = brandReadModelRepository.existsById(brandId)
if (!brandExists) throw BrandNotFoundException(brandId)
// ② 商品作成
// ⚠ ①と②の間でブランドが削除される可能性(レース条件)
val product = Product.create(brandId, productName)
productRepository.store(product)
}
}
このリードモデル参照は「無いよりまし」程度の位置づけであり、完全な整合性は保証できない。
他集約の状態確認方法(コマンド側で完結する方法)
リードモデルではなく、コマンド側の仕組みで他集約の状態を確認する。
方法1: 集約に直接問い合わせ(アクターモデル)
// Akka/Pekko: 組み込みメッセージで存在確認
brandActorRef ! Identify(brandId)
// → ActorIdentity メッセージが返る
// Typed Actor: カスタムメッセージで状態確認
brandActorRef ! ExistsBrand(brandId, replyTo = self)
方法2: リポジトリ/ドメインサービスで参照(参照のみ)
// ✅ 他の集約をリポジトリで参照するだけなら許可(更新は不可)
class CreateProductUseCase(
private val productRepository: ProductRepository,
private val brandRepository: BrandRepository, // コマンド側のリポジトリ
) {
fun execute(brandId: BrandId, productName: String) {
// コマンド側のリポジトリで存在確認(リードモデルではない)
val brand = brandRepository.findById(brandId)
?: throw BrandNotFoundException(brandId)
val product = Product.create(brandId, productName)
productRepository.store(product)
// ※ brandの更新はしない(参照のみ)
}
}
注意: 参照はDDD原則に反しないが、複数集約の更新を同一トランザクションにすることは不可。
Application層での複数集約操作の原則
| 操作 | 可否 | 理由 |
|---|---|---|
| 他集約の参照 | OK | 読み取りのみなら問題なし |
| 他集約の更新(別トランザクション) | OK | 結果整合性で対応 |
| 他集約の更新(同一トランザクション) | NG | 集約の整合性境界を破壊する |
イベントストアの制約
イベントストアは基本的に集約IDでしかアクセスできない。
可能: 商品ID → 商品集約のイベント履歴
不可: ブランドID → そのブランドに紐づく商品の一覧
「このブランドIDに紐づく商品があるか?」という逆引きはイベントストアでは直接実行できない。
逆引きを実現する場合のコスト
逆引きが必要な場合、ブランドIDから商品IDを解決できるインデックス用リードモデルを別途用意し、商品の登録・更新・削除のたびにそのインデックスも更新する必要がある。
Product集約 → ProductCreatedEvent → リードモデル更新
↓
Brand-Product インデックス
↓
Brand削除時 → インデックスを参照して紐づき確認
できなくはないが、かなり複雑になる。 その複雑さに見合うビジネス価値があるかを先に検討すべき。
ステップ5: 覚悟を決める
CQRS/ESを採用する覚悟
CQRS/ESの非同期・イベント駆動的な性質上、以下は避けられない:
- イベント処理遅延による一時的な不整合
- 補償トランザクション失敗時のゴミデータ
- リードモデルと書き込みモデルの一時的なズレ
これらを「問題」と見なすのではなく、分散システムの基本的な特性として設計段階から組み込む必要がある。
CQRS/ESをやるんだったらそれぐらいの覚悟を持たないとだめ
許容すべきこと
| 許容すべき | なぜ |
|---|---|
| 一時的な不整合データ | 結果整合性の本質 |
| 紐づき先のないデータ | 集約の独立性の代償 |
| リードモデルの遅延 | 非同期処理の本質 |
許容してはいけないこと
| 許容不可 | なぜ |
|---|---|
| 集約内部の不整合 | 集約は強い整合性境界 |
| ビジネスルールの破壊 | 不整合と要件違反は別 |
| 永続的なデータ不整合 | 結果整合性は「最終的に一致する」こと |
判断フロー(全体)
集約間の制約チェックが必要になった
↓
1. その制約は本当に必要か? → 要件を疑う
├─ 不要 → 制約を廃止。問題解消
└─ 必要 ↓
2. そもそもモデリングの問題ではないか?
├─ 1:1の関係 → 同一集約に統合。制約チェック不要に
├─ 片方がクエリ要件 → CQRSのリードモデルで対応。集約不要
└─ 独立した集約である必要がある ↓
3. Sagaを誤用しようとしていないか?
├─ 制約チェックにSaga → 不適切。Sagaは分散トランザクション管理用
└─ OK ↓
4. コマンド側だけで解決できるか?
├─ YES → 他集約にリポジトリ/メッセージで参照(リードモデル依存は不可)
└─ NO ↓
5. リードモデルでの「ベストエフォート」チェックで許容できるか?
├─ YES → リードモデル参照 + レース条件を許容 + 結果整合性
└─ NO ↓
6. 強い一貫性が絶対に必要か?
├─ YES → 集約境界の見直しが必要(設計の問題)
└─ NO → 結果整合性 + ゴミデータの許容
関連スキルとの関係
| スキル | 関係 |
|---|---|
aggregate-design |
集約内部の設計原則。本スキルは集約間の制約を扱う |
aggregate-transaction-boundary |
トランザクション境界と結果整合性。本スキルは制約チェックに焦点 |
cqrs-tradeoffs |
結果整合性の概念的トレードオフ。本スキルは実践的な覚悟を扱う |
レビューチェックリスト
要件
- 集約間の制約が本当にビジネス上必要か問い直したか
- ライフサイクル全体で制約の実効性を検証したか
- 要件緩和の可能性を検討したか
設計
- Sagaを制約チェックに誤用していないか
- 同一集約への統合の可能性を検討したか(1:1関係なら統合が自然)
- 片方が純粋なクエリ要件なら、CQRSのリードモデルで対応できないか
- イベントストアの逆引き制約を理解しているか
- コマンド側がクエリ側(リードモデル)に依存していないか
- 他集約の参照はコマンド側のリポジトリ/メッセージングで行っているか
- Application層で複数集約の更新を同一トランザクションにしていないか
実装
- 逆引きが必要な場合、リードモデルのコストを見積もったか
- 一時的な不整合データの存在を許容する設計になっているか
- 複雑さに見合うビジネス価値があるか評価したか
関連スキル(併読推奨)
このスキルを使用する際は、以下のスキルも併せて参照すること:
aggregate-design: 集約境界の設計(制約問題の根本原因)aggregate-transaction-boundary: 1トランザクション=1集約ルールと結果整合性cqrs-aggregate-modeling: CQRS/ESによるイベント駆動の結果整合性