かとじゅんの技術日誌

技術の話をするところ

「DDDで複数集約間の整合性を確保する方法」に対する考察

久しぶりにブログ記事を書きますか。

ということで、松岡さん(id:little_hands)のブログ記事に対する考察記事です。

この記事は古くなったので、ぜひ以下も参照してください。

blog.j5ik2o.me

little-hands.hatenablog.com

題材も松岡さんのブログ記事と同じもので考えます。

「実装方法1. ユースケースで複数集約を更新する」について考察したいと思います。

注意事項)この記事で使うトランザクションという用語は単なる一連の手続きという意味ではなく、ACID特性を持つRDBのトランザクションという意味です。

class CreateTaskUseCase1(
    private val taskRepository: TaskRepository,
    private val taskReportRepository: TaskReportRepository,
) {

  @Transactional
  fun execute(taskName: String) {
    // Taskの作成と保存
    val task = Task(taskName)
    taskRepository.insert(task)

    // TaskReportの作成と保存
    val taskReport = TaskReport(task) // 生成したTask経由でTaskReportを作成している
    taskReportRepository.insert(task)
  }

}

詳しくみていくと、@Transactionalで一つのトランザクションとしていて、TaskとTaskReportは一緒に更新されないといけないようですね。あれ、これってそもそも集約としておかしくないか?と思いました。

集約の外部では結果整合性を用いる

Evansの集約は

複数の集約にまたがるルールはどれも、常に最新の状態にあるということが期待できない。イベント処理やバッチ処理、その他の更新の仕組みを通じて、他の依存関係は一定の時間内に解消できる。[Evans](128ページ)

であると、実践ドメイン駆動設計の「10.5ルール:境界の外部では結果整合性を用いる」で紹介されています。

ひとつの集約上でコマンドを実行するときに、他の集約のコマンドも実行するようなビジネスルールが求められるのなら、その場合は結果整合性を使うこと。

この説明に従うと、以下のようになるはずです。一つのトランザクションにまとめあげません。集約どうしはそれぞれ結果整合性を使うので、強い整合性がないからです。

class CreateTaskUseCase1(
    private val taskRepository: TaskRepository,
    private val taskReportRepository: TaskReportRepository,
) {

  fun execute(taskName: String) {
    // Taskの作成と保存
    val task = Task(taskName)
    taskRepository.insert(task) // Taskのトランザクション

    // TaskReportの作成と保存
    val taskReport = TaskReport(task)
    taskReportRepository.insert(task) // TaskReportのトランザクション
  }

}

もちろん、TaskReportの更新が失敗したらTaskのロールバックが面倒では?というのあります。とはいえ、集約としての定義はこうなるはずです。

実践ドメイン駆動設計

実践ドメイン駆動設計

まぁ、たぶん、えーっそれは困るんです。Task集約とTaskReport集約は一緒に更新されないといけないんです、って話ですよね…。その理由がちょっとわからないから考えるのが難しい…。もう少し要件知りたい…。

Lightbend Academyでも集約について以下のように解説されています。

Transactions should not spend multiple aggregate roots.
If you find yourself in a situation where you need to do a transaction that actually crosses aggregate roots, then you've either defined your aggregate roots incorrectly or there's a problem with your transaction and you might have to rethink it a little bit.
トランザクションは、複数の集約ルートを費やすべきではありません。
もし、実際に集約ルートを横断するトランザクションを行う必要がある状況に遭遇した場合は、集約ルートの定義が間違っているか、トランザクションに問題があり、少し考え直す必要があるかもしれません。

とあるので、TaskとTaskReportの場合も、そもそも集約の定義やトランザクションの扱いに問題あるということではないでしょうか。

Will a transaction span multiple entities? If the answer to that question is yes then we can safely say that we have got the wrong aggregate root.
Because again a transaction should not span multiple aggregate root.
トランザクションは複数のエンティティにまたがりますか? この質問の答えがイエスならば、間違った集約ルートを持っていると言っても良いでしょう。
なぜなら、トランザクションは複数の集約ルートにまたがるべきではないからです。

あくまでEvansの集約の定義は、トランザクションなどの強い整合性が有効に働く範囲は集約内部だけという話です。逆に、集約間では結果整合性なので弱い整合性しか使えません。

モデリングに問題はないか

TaskとTaskReportがそれぞれ独立した集約というならば、ここに示されたようにトランザクションは独立していないとおかしいのです。なのに、CreateTaskUseCase1#executeではこれらの集約を一つのトランザクションで更新している。強い整合性で結びつけているのです。

そして”うっかりTaskReport作成を忘れてしまった”がまずいということは、TaskとTaskReportは独立して更新できないことを言っているに等しい。であれば、そもそもTaskとTaskReportは同じ一つの集約ではないのか…と考えます。仮に、そもそもすべてが集約内部に包含されれば、うっかり更新をミスっておかしな状態にならないわけです。

val task = Task(taskName, TaskReport(...))
taskRepository.insert(task)

というわけで、TaskとTaskReportは分かれていないとまずいんです!(再)と聞こえてきそうです(笑) これだと要求を満たせないというのであれば、モデリングに問題あるのでは感。他の実装方法も議論したいところですが、要件をもう少し確認したいなぁという感じ。

追記:少しTwitterで松岡さんと話したので観点を追加(3/10)

集約の境界=強い整合性の境界

よくあるパターンとしては、Task : TaskReport = 1 : N の関係で、Nが一つのオブジェクトに内包できないぐらい大量にあるという話。 この場合は、とれる妥当なオプションとしては以下?

Task : TaskReport = 1 : N の関係

  1. Nを十分に小さくできないか、要求を調整する。たとえば、注文伝票の注文詳細欄は1万行とかある?ないでしょって話
  2. Nを十分に大きく取らざるを得ないなら、別々の集約として独立させて結果整合を許容する

1の場合、Task(TaskReport)だけじゃなくUser(UserReport)という構造もある場合、レポートの知識が分散してしまうとデメリットがあるのではないかという話。レポートがクエリ要件ならTaskReportとUserReportをマージしたリードモデルがあればよい。そうでなくドメインの知識なら型や集合として単一である必要があるのでReport型という独立した集約が必要なので2になる。あと、CQRSの観点でC側のドメインオブジェクトを最適化すれば1を満たせる場合がある。詳しくは → CQRS/ESによって集約の境界定義を見直す - かとじゅんの技術日誌

基本的に1,2で考えれば、集約の構造をみれば整合性の境界が分かる。1対1に紐付く。ユースケースで複数の集約を同一トランザクションに入れてしまうとこれは不鮮明になる。

実践ドメイン駆動設計では、複数の集約を同一のトランザクションで更新するリスクを以下のように述べている。

集約の境界が実際の業務の制約と一致していると仮定して、もしビジネスアナリストが図10‒4のような仕様を出してきたら、それは問題の元だ。考え得るコミット順を考慮していくと、三つのリクエストのうち二つが失敗するいう場合もあることがわかる。この指示が、あなたの設計にどんな影響をおよぼすのだろう?この問いに答えようとすると、ドメインについてのより深い理解が得られる。複数の集約のインスタンスの整合性を保ち続けなければいけないというのは、自分たちが不変条件を見落としているということを意味する。最終的には、その複数の集約をひとつの新たな概念にまとめて名前をつけて、この新たに発見した業務ルールに対応することになるだろう(そしてもちろん、今までの集約群を、この新しい概念に取り込むことになる)。

このようなトランザクションの競合問題を除けば、ほとんどの場合は、複数集約を同一トランザクションにまとめても問題ないだろう。普通に機能するし便利だと思われる…。つまるところ、集約が強い整合性の境界を作るのに対して、ユースケースクラス(アプリケーションサービス)では弱い整合性の境界を作っていると考えてもよい。整合性の境界が入れ子だとしても強い一貫性を発揮する境界は集約であることは変わりない。もちろん、個別ケースにおいてはリスクを取って同一トランザクションに含めることもあるが、原則論としてはこういう考え方になるのは妥当だと思う。

ユースケースクラス(アプリケースサービス)で同一トランザクションにしないで複数の集約を更新する際、ダブルコミットの問題が生じるので、そのリカバリーが厄介になる。以下のように集約Bの更新が失敗した場合、集約Aはすでにコミットされているので、集約Bはリトライしなければならない。もしくは集約Bの更新が失敗しても、クライアントからユースケースAがべき等にリトライできるようにする必要がある。そもそも集約は独立した存在なので、集約Bの更新が失敗しても集約Aへの影響は大きくないはず。クエリサイドで集約Aと集約Bを合成した結果を取得する際は、集約Bだけ古いデータを見ることになるので、こういった制約を想定しておく必要がある。

ユースケースA() {
  集約A更新
  集約B更新
}

集約単位のトランザクションはモジュラリティ確保のために

まぁ、こういう想定をすると益々同一トランザクションに入れたくなるが…僕は入れません。

集約Aと集約Bの更新を同一トランザクションにした場合、ユースケースAを後から分離することが難しくなります。スケーラビリティのために、集約Aと集約Bを別々のサーバで強い整合性を持って実行するということは無理に等しいです(そもそも分けて実行する要求がないと言い切れるなら無視していいことですが…)。両者の更新が常にセットで行われることを期待したロジックができあがり、そのロジックには依存関係が生じます。あとからトランザクションを分けようとすると、大部分の他のロジックが動作しなくなるということはざらにあります。もちろんコードの規模や複雑度にもよりますが…。

// 同一トランザクション前提のロジックを、以下のように分割することはかなり難しい…。
サーバAのユースケースA() {
  集約A更新
}

サーバBのユースケースB() {
  // 集約Aが正しく更新されていることが前提だったが、まだ更新されていないかもしれない…。
  集約B更新 
}

あと、集約Aと集約BのストレージがRDBなら同一にできますが、それぞれ別々のDBであったり、NoSQLであったりする場合はそもそも無理ですね。つまり、@Transactionalなどの手法で一つのトランザクションすることには、同一DBインスタンスを利用するという暗黙的なコンテキストがある。DBを分割したり異種のDB I/Oというコンテキストは含まれない。やはりモジュール性に関する論点かもしれない。

DBを例にしたが、マイクロサービスアーキテクチャの場合はどうだろう。こちらもユースケースで同一トランザクションにすることなどはそもそも無理だ。

ユースケースA() {
  マイクロサービスAの集約A更新
  自マイクロサービスの集約B更新 
}

この場合でも、前節で述べたようにRDBのトランザクションには頼れないので、別のリカバリ方法が必要になります。ここでは詳しく述べませんが、Sagaというテクニックがあります。Sagaは、ACIDの分離性がないことによって引き起こされる並行性の問題を防止・軽減する設計テクニックです。興味がある人は以下の書籍の4章を読むとよいです。こちらの文脈を辿っていくとマイクロサービスなどの分散システムではACIDトランザクションではなく分散トランザクションが必要であることに気づくでしょう。逆にいえば、マイクロサービスではない環境下では、暗黙の大前提としてRDBだからDBのトランザクションを何の疑問もなく使えてしまうというのはあるのではないでしょうか。

長々と書きましたが、トランザクションを同じにするということはモジュールとしても強い依存関係をつくりあげてしまうということではないかと思います。モジュラリティとか疎結合のために集約単位にトランザクションを分けているといってもいいでしょう。

追記: 2021/3/15