kbigwheelのプログラミング・ソフトウェア技術系ブログ

プログラミング・ソフトウェア技術系のことを書きます

「集約の境界と整合性(略」に対して頂いたアイデアの分類と現状での僕の回答らしきもの


先日ドメイン駆動設計 #1 Advent Calendar 2018 - Qiitaの1日として次の記事を公開しました。

kbigwheel.hateblo.jp

当初2,3人からアイデア頂ければ御の字だと思っていたのですが、思っていたよりずっと多くの人からたくさんのアイデアを頂くことができました。本当にありがとうございました。 僕が2ヶ月ぐらい考えても全然思いつかなかったアイデア・意見がたくさんあって、やっぱり一人では発想に限界があるなと痛感しました。

このまま僕だけが頂いたアイデアを知っているのは勿体無いので、この記事では頂いたアイデアを解決策1~4へ分類しつつ更に掘り下げました。 なるべく正確に理解するよう努めたつもりですが、間違って解釈していたらすみません。 はてぶやtwitterで直接メンションが飛んでいないつぶやきなども拾っていますので、もし私的な記述なのでここに載せないでくれという方居られましたらここのコメント欄かtwitterのDMなどで連絡をください。できるだけすぐに消すよう努めます。

解決策1 集約をマージするタイプのアイデア

皆無でした。 やはり多くの人がこの方向の解決はまずいパターンだと認識されているようです。

解決策2 一時的な整合性の破綻を受け入れ結果整合性を使うタイプのアイデア

DDDとして正道であることもあり、最も多くのアイデアが出ました。

A. 組織へ無効状態で追加後、有効化するアイデア

A.i 連続的に追加・有効化を行うアイデア

A.ii 追加後、有効化を別スレッドやイベント駆動で行うアイデア

B. 組織に所属するユーザー一覧というドメインオブジェクトをアイデア

B.i ユーザー一覧を組織集約に持たせるアイデア

B.ii ユーザー一覧を独立した集約にするアイデア

C. イベント駆動アーキテクチャの長期プロセス(サーガ)的なアイデア

読んでいてIDDDでいうサーガ(長期プロセス)のイメージなのかなと思いました。解釈が間違っていたらすみません💧

D. オーバーがNGなら逆に現在ユーザー数を先に増やすアイデア

解決策3 アンチパターンではあるが集約間の整合性維持のためトランザクション制御を用いる

こちらは解決策1と比べて一定の言及はあるものの、やはり積極的な支持はほぼありませんでした。

解決策4 ユースケースの見直しによる再モデリングタイプのアイデア

2番目に多かったのがこのタイプです。 ユースケースの見直し、具体的にはユーザー上限ルールの稀な逸脱を許容する方向のアイデアが多かったです。

僕の現時点での回答らしきもの

まず解決策1と3はほとんど支持されていなかったため、一旦除外しました。 一定のアイデアが出ていた解決策4ですが、こちらも一旦許容しない(できない)として解決策2での手法を模索することにしました*2

次に解決策2タイプのアイデアを見ていくと、ほとんどの人がユーザーの有効/無効の状態をユーザー集約側ではなく組織集約側に持たせようとしています。 考えてみるとたしかにユーザーが有効かどうかは組織側が持っていたほうが自然です*3

また、頂いていた中でイベント駆動アーキテクチャ的な非同期的、長期プロセス(サーガ)的方法を一旦後回しにしました。 従来の同期的でコンパクトな方法で済むならそのほうが良い気がするのが半分、現状僕がどうしてもそちらの知識・経験が少ないため自身を持って設計できないことが半分です*4

というわけで、残った解決策2の中から最終的に選択したのがB.i案です。

現時点での回答: 解決策2 B.i 案

ユーザーエンティティが持っていた有効/無効の状態が、組織エンティティ内の有効ユーザーのユーザーID一覧という形に変わっています。 またユーザーの組織への所属関係もユーザーエンティティに持たせるのではなく組織エンティティに持たせるようにしました。

コードは以下のように変わりました。

// アプリケーション層のどこか

def `ユーザーを有効状態で追加`(
    `組織ID`: Int,
    `ユーザー名`: String,
    `メールアドレス`: String,
    `ハッシュ化されたパスワード`: String): Try[`ユーザー`] = {

  val `組織インスタンス` = `組織リポジトリ`.find(`組織ID`).get

  if (! `組織インスタンス`.有効ユーザーが上限に達している()) { // ①
    val `ユーザーインスタンス` = `ユーザーファクトリ`.create(`ユーザー名`, `メールアドレス`, `ハッシュ化されたパスワード`)
    `ユーザーリポジトリ`.save(`ユーザーインスタンス`)

    // ここから
    `組織インスタンス`.有効状態でユーザーを追加(`ユーザーインスタンス`.`ユーザーID`)
    `組織リポジトリ`.save(`組織インスタンス`) match { // ②
      case Success(_) =>
        Success(`ユーザーインスタンス`) // ③
      case Failure(e: VersionChangedException) =>
        `ユーザーリポジトリ`.delete(`ユーザーインスタンス`.`ユーザーID`) // ④
        Failure(e)
    }
    // ここまでが追加された
  } else
    Failure(new `有効ユーザー数上限Exception`)
}

以前と同じ例を使うと以下のようになります。

  • 有効ユーザー数上限が30, 現在の有効ユーザー数が29
  • ユーザー追加メソッドが並行でほぼ同時に2度呼ばれる(それぞれをコールA, コールBとする)
時系列 コールA コールB
1 ①が実行され、29 < 30であるため②に進む
2 ①が実行され、29 < 30であるため②に進む
3 ②が実行される。
読み込んだときから状態(バージョン番号)が
変わっていないため成功して③に進む
4 ②が実行される。
読み込んだときから状態(バージョン番号)が
変わっているため失敗して④に進む
5 Successが返る
6 ④が実行され、有効化できなかったユーザーを削除しておく
7 Failureが返る

これにより有効ユーザー数は上限を超えることなく維持できました。

これだとユーザー上限に達していなくても更新が重なるとコールBで例外が発生しますが、メソッド呼び出し側で例外がVersionChangedExceptionだった場合はリトライすればOKです。

④のユーザー削除は意見の分かれる所かもしれません。 私はユーザーインターフェイス層から見たとき、このメソッドがユーザーの有効状態での追加 or 何もしない*5で返してくれると便利だと思いこのようなメソッドにしました。組織に属しない宙ぶらりんなユーザーが残っていると混乱しやすいと思ったためです。 逆にユーザーインターフェイスからユーザーの無効状態の追加と有効化を細かい粒度で制御したい場合はこのメソッドは内容を2つに分離していいと思います。

もう1つ言及したい点が④を実行前にこのアプリケーションやコンテナが死んだ場合で、そのときは宙ぶらりんなユーザーが残ってしまいます。しかし、この状態のユーザーは検出がしやすく残っていても問題になりづらいという点でより許容しやすいと思いました。 これが後述のA.i案よりこちらを選んだ最も大きな理由です。

ほぼBiと同じ: 解決策2 B.ii 案

クリックすると大きい画像が開きます

こちらはB.i案のユーザー一覧に関する値オブジェクトを独立した集約に切り出したものです。 今の組織集約は非常に簡素なため今すぐ切り出す必要性はないように見えますが、組織エンティティに機能や要素がもっと増えてきたらこうした方がよいでしょう。

コード的にはほぼ同様になるため省略します。

一長一短あり: 解決策2 A.i

クリックすると大きい画像が開きます

この案ではユーザーの有効/無効状態のみ組織集約に移しており、どのユーザーがどの組織に属するかの情報はユーザー集約に残しています。 コードはほぼ同じですので割愛します。

このパターンはB.i 案と比べていくつかのメリット・デメリットがあります。

  • メリット
    1. ユーザーが必ず1つの組織に属すること*6ドメインモデルから自明にできる
  • デメリット
    1. 相互参照は注意して扱わないと簡単に整合性が取れなくなってしまう*7
      • 例: 有効な状態のユーザーをユーザーエンティティの組織IDだけ別の組織へ変えてしまうと、元の組織にその組織へ属さないにもかかわらず有効登録されたユーザーIDが残ってしまう

また特筆するべき困った点が④を実行前にこのアプリケーションやコンテナが死んだ場合で、B.i案とは違い先に作られたユーザーは実際に組織へ無効状態で追加されています。 そのため、他の単純に無効化されているユーザーと区別することが難しく、リカバーにより手間がかかると感じました。

まとめ

というわけで、一旦現時点では解決策2 B.i案の「ユーザー一覧を組織集約に持たせるアイデア」が良いと思いました。 むろんこれが正解だという確信はまったくありません。細かい条件の違いで如何用にも最適解は変わりますし、僕が説明されていただいたシチュエーションでも2 B.i案が本当に最良か、試してみないことにはわかりません。

頂いたアイデアのおかげで考えがまとまりました。 一人で考えるだけではここまで早く良い案には辿りつけなかったと思います。 本当にありがとうございました。あとは実際にやってみて出した案が本当に良いか確かめようと思います。

2018/12/12 追記

cactuaroid(@cactuaroid)さんから上のような指摘がありました。 それと、ファクトリによりIDがcreate時に発行される前提で書いたコードが以下です。 もっとモナドらしい自然な書き方ができると思うんですが、慣れていないので愚直な書き方で失礼します。

ユーザーはsaveできたが組織インスタンスが何らかのエラーでsaveできなかったときのために①を書いていますが、これを省くと更にコードが完結になります。 ただ、その際はそうやって発生する不整合状態を復帰させる(どの組織にも紐付かないユーザーを削除する)ようなデーモンないし仕組みが欲しくなるでしょう。

2019/01/20 追記

https://twitter.com/j5ik2o:かとじゅんさんから上のようなアイデアをいただきました。 (僕が勘違いをしていなければ)そのアイデアを元に書いた疑似コードが以下です。

cactuaroidさんから頂いたアイデアのコードと比べると、集約の保存の順番が ユーザー集約 → 組織集約 から 組織集約 → ユーザー集約 に変わっています。 楽観的ロック的に考えると組織集約の更新は同時に同じ集約へ更新が走ると衝突する可能性が一定あるのに対してユーザー集約の新規保存は衝突する可能性が(ほぼ)ありません。 そのため、仮で作ったユーザーを削除する必要がないという点で優れていると言えます。

一方で、組織集約の更新直後にアプリケーションがkillされたりDBクラスタがダウンしたりする可能性を考慮すると少し厄介です。 組織集約のユーザーID一覧に実際には存在しないユーザーのユーザーID(以後ゴーストユーザーID)が残ってしまうため、集約を参照する側はユーザーID一覧内のユーザーIDが実在しない可能性をコード中で考慮する必要が出てきます。 また、そのようなゴーストユーザーIDを定期的にクリーンアップする必要が出てきますがこれは2018/12/12の案での組織に紐付かないユーザーを削除するクリーンアップ処理に対応します。

※ すみません、ここら辺の結果整合性とシステムダウンの関係と復帰のさせ方については勉強不足かつ考え不足で自分の言葉にできていないので、後日また記事を書いて言葉にしようと思います。

関連リンク

*1:最初読んだとき天才か・・・と思いましたがトリッキー過ぎて書いた自分以外にうまくこのロジックの意味を伝えるのが大変かなと思いました

*2:解決策2が駄目だったら4へフォールバックしたらいいかなと

*3:ここについてはRDBMSの正規化の視点から抜け出せていなかったなと反省しました

*4:イベント駆動的ソリューション・アイデア分野は本当に力不足を感じます、すみません。。。

*5:ユーザーすら作れなかった

*6:組織に属さないユーザーや2つ以上の組織に属するユーザーが存在しないこと

*7:DDD本でも相互参照は基本的に避けるべきという記述があったと思います、たしか