集約の境界と整合性の維持の仕方に悩んで2ヶ月ぐらい結論を出せていない話 - kbigwheelのプログラミング・ソフトウェア技術系ブログ
個人的に、非常に面白い記事なので、私見を晒したいと思います。
「上の何が問題?」 のコード例について(細かい指摘)
このコードは、ユーザーリポジトリ
を呼び出す時点で、ドメイン層ではなくアプリケーションサービスの責務に見えます(一般的にドメインオブジェクトは永続化責務を持たないためです)。あと、以下の理由もあり、createは使わない方がよいと思います。createは生成責務に見えるのでユーザファクトリではないでしょうか?
あと、Repository#createは、リポジトリなのに生成責務あるの?と誤解しやすいので、Repository#putとか#storeとかのがよいと思いますよw
— かとじゅん (@j5ik2o) 2018年11月30日
感想
はい。本題の方です。
IDDD本に従いトランザクション制御は集約内(≒単一リポジトリ内)で完結させているとすると3、上のコードは一切集約間の整合性の維持を気にかけていません。ですのでユーザー上限ルールは以下のように容易に破綻します。
についてはだいたいわかります。というか、組織とユーザには強い整合性が適用できないので、そのうえで成り立つ不変条件(ユーザー上限ルール)も適用できないということになります。この例ではどうしても結果整合になるので、言及されているとおりの状態になります。
「解決策1 集約をマージする」について
IDDDで極端な例として「巨大な集約」がありますが、これも同様ですね。同時・並行に更新する際にロックエラーが起こりやすくなり、ユーザビリティが著しく低下する問題がありますね。こういう集約はユースケースを実現することが困難になるので、見直しを迫られるでしょうね。(ちなみに、この節のコード例もアプリケーションサービスのコードになります)
「解決策2 一時的な整合性の破綻を受け入れ結果整合性を使う」について
最初のモデル図の設計をそのまま実装したらという話ですかね。僕はこっち派です。
困るケースは2パターンあるかなと思っています。
- 集約Aと集約Bを順番に更新する
- 集約Aの更新が成功したが集約Bが失敗したときどうリカバリするか。クライアントにはエラーを返し、クライアントにリトライしてもらう。つまり、集約Aの更新しからやり直す。集約Aのゴミデータが残るけど、読み込み側の実装で工夫するしかないと思います。このあたりの対応は泥臭いですが、それほど難しくないと思います
- 集約Aの状態をもとに集約Bを更新する
- 今回のケースはこちらですかね。コード例のチェック①をしたからといっても、他のサーバでの更新がチェック後に割り込まれると破綻しますよね…。おっしゃるとおり整合性のルールを維持することは難しいですね。
- 対策を2つぐらい考えてみました
- 前提:更新処理の結果をすぐに返さずに、要求を受け付けて結果は非同期に返すこと
-
プラン1:無効化されたユーザも見える方式
- 更新リクエストを受け取ったら、現在の組織のユーザ上限を確認し、ユーザを無効状態で追加保存する。
- 組織IDに対応するワーカーを使って、ユーザ追加日時順でソートして、ユーザ上限以内のユーザを有効化する。これを周期的に行う。
- ユーザリストでは有効されたユーザだけでなく、無効なユーザも表示できるようにする。追加できない状態をなくしているので、エラーハンドリングはしやすい。
-
プラン2:ユーザ上限までしか追加しない方式
- フロントのサーバから更新リクエストを、組織ID毎のキューへエンキューする。
- 組織IDに対応するワーカーがそのキューから更新リクエストをデキューし、DBへの更新処理をシリアルに行う
- 更新が失敗したエラーイベントを、呼び出し側のクライアントに通知する必要がある。クライアントからのポーリングは避けたいところ、できればクライアントへのプッシュがよい。
経験上ほとんど1)のパターンが多いと思ってます。2)はやっかいですが、ユーザー上限ルールというビジネスルールを厳密に守る必要が本当にあるかという議論になりますね。厳密に守らざるを得ない場合は、僕なら上記のとおりワーカーなどの別の仕組みを使います。まぁ大掛かりですが、ROIがペイするのであれば…。
「解決策3 アンチパターンではあるが集約間の整合性維持のためトランザクション制御を用いる」について
こちらのパターンはよくやる方法ではありますが、問題は集約の境界があいまいになることです。以下のスライド資料を参考にしてもらうとわかりやすいと思います。仮に、集約A単体でも、集約B単体でも、集約A+集約Bの組み合わせでも、トランザクション境界(強い整合性の境界)を作っているとしたら、更新が競合する可能性はあるし、そもそも整合性の境界がどこまでか時と場合によって変わりますという矛盾を表現しているようなものなので、普通に避けたいところです。こういうことが特にないのであれば、このパターンでもよいと思います。
FYI: こんなユースケースは要注意 - Scalaでのドメインモデリングのやりかた
pospomeさんの、こちらの記事の方がわかりやすいです。
DDDにおいて、なぜ複数の集約にまたがってトランザクションをかけてはいけないのか(multiple aggregates in one transaction) - pospomeのプログラミング日記
「解決策4 ユースケースの見直しによる再モデリング」について
こちらは自分たちのコントロールがなかなか効かないところではありますね。とはいえ、ユーケースはビジネス上の守るべき不変条件からも影響を受けるはずなので、手順だけの見直しではなくそこに厳密な整合性を求める理由などが明らかになるといいですね。まぁ、どうしても必要だと言われると、上記のようなリカバリ手段が必要になってきてしまうと思いますが…。