--- name: cross-aggregate-constraints description: > 集約間の制約チェック(「集約Aのユースケースで集約Bの状態を確認したい」)に対する 設計判断を支援する。Sagaの誤用検出、ビジネス要件の問い直し、CQRS/ESにおける 技術的制約の理解、不整合データの許容判断を含む。 コードレビュー、アーキテクチャ設計、要件分析時に集約間の制約問題に直面した場合に使用。 対象言語: 言語非依存。 トリガー:「集約間の制約チェック」「他の集約の状態を確認したい」「Sagaで制約チェック」 「ブランドに商品が紐づいているか確認」「集約間のバリデーション」 「イベントソーシングで逆引き」「集約をまたぐビジネスルール」「CQRS/ESの制約」 「コマンド側からリードモデルを参照していい?」「ユースケースで他の集約を参照」 といった集約間制約関連リクエストで起動。 --- # 集約間の制約チェック 集約間の制約に直面したら、まず要件を疑い、次に技術的制約を理解し、最後に覚悟を決める。 ## 典型的な問題 「集約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の作成を忘れるとまずい」ならば、両者は独立できない可能性が高い。 ```kotlin // 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の技術的制約を理解する ### 大原則: コマンド側はコマンド側だけで解決する **コマンドがクエリ側のリードモデルに依存してはならない。コマンド側はコマンド側だけで解決できないと設計が破綻する。** ``` ❌ 禁止: コマンド側 → リードモデル(クエリ側)を参照して判断 ✅ 許可: コマンド側 → 他の集約にコマンド/メッセージで問い合わせ ``` **なぜリードモデルに依存してはいけないか**: | 理由 | 説明 | |------|------| | イベントの非同期性 | イベントストアへの書き込みとリードモデルへの反映の間にラグが発生する | | レース条件 | リードモデル参照→コマンド実行の間に状態が変わる可能性がある | | 責任分離違反 | コマンドモデル自体の検証ロジックが曖昧になる | | 結果整合性との矛盾 | 強い整合性が必要な操作で結果整合性に依存する危険性 | ### アンチパターン: リードモデルで事前チェック ```kotlin // ❌ コマンド側がクエリ側に依存している 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: 集約に直接問い合わせ(アクターモデル)** ```scala // Akka/Pekko: 組み込みメッセージで存在確認 brandActorRef ! Identify(brandId) // → ActorIdentity メッセージが返る // Typed Actor: カスタムメッセージで状態確認 brandActorRef ! ExistsBrand(brandId, replyTo = self) ``` **方法2: リポジトリ/ドメインサービスで参照(参照のみ)** ```kotlin // ✅ 他の集約をリポジトリで参照するだけなら許可(更新は不可) 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によるイベント駆動の結果整合性