- 解決策1 集約をマージするタイプのアイデア
- 解決策2 一時的な整合性の破綻を受け入れ結果整合性を使うタイプのアイデア
- 解決策3 アンチパターンではあるが集約間の整合性維持のためトランザクション制御を用いる
- 解決策4 ユースケースの見直しによる再モデリングタイプのアイデア
- 僕の現時点での回答らしきもの
- まとめ
- 2018/12/12 追記
- 2019/01/20 追記
- 関連リンク
先日ドメイン駆動設計 #1 Advent Calendar 2018 - Qiitaの1日として次の記事を公開しました。
当初2,3人からアイデア頂ければ御の字だと思っていたのですが、思っていたよりずっと多くの人からたくさんのアイデアを頂くことができました。本当にありがとうございました。 僕が2ヶ月ぐらい考えても全然思いつかなかったアイデア・意見がたくさんあって、やっぱり一人では発想に限界があるなと痛感しました。
このまま僕だけが頂いたアイデアを知っているのは勿体無いので、この記事では頂いたアイデアを解決策1~4へ分類しつつ更に掘り下げました。 なるべく正確に理解するよう努めたつもりですが、間違って解釈していたらすみません。 はてぶやtwitterで直接メンションが飛んでいないつぶやきなども拾っていますので、もし私的な記述なのでここに載せないでくれという方居られましたらここのコメント欄かtwitterのDMなどで連絡をください。できるだけすぐに消すよう努めます。
解決策1 集約をマージするタイプのアイデア
皆無でした。 やはり多くの人がこの方向の解決はまずいパターンだと認識されているようです。
解決策2 一時的な整合性の破綻を受け入れ結果整合性を使うタイプのアイデア
DDDとして正道であることもあり、最も多くのアイデアが出ました。
A. 組織へ無効状態で追加後、有効化するアイデア
A.i 連続的に追加・有効化を行うアイデア
- TANIGUCHI Kousukeさん: ユーザー追加を無効状態で追加して、アクティベーションする役割を組織の役割として持つのでよいのではないでしょうか? 早すぎる抽象化だとは思いますが、精緻に行うなら有効ユーザー上限は具象であって、そこにあるべきは有効化ポリシーだと思います。…
- nrsさん: 自分がやるときは組織に現在の有効ユーザの id 配列を持たせそう あとは有効化させてから無効にするのではなく、ユーザ追加とユーザの有効化の二つの処理に分割して、ユーザ追加は成功するけどユーザの有効化は失敗する可能性がある、という結果整合性に寄せていきそう…
- こむさん: 一般論なら結果整合で、今回の具体例ならユーザーを無効にしておいてユーザー数制限にカウントしない (略)、かな。
A.ii 追加後、有効化を別スレッドやイベント駆動で行うアイデア
- かとじゅんさん: 集約の境界と整合性問題に関する感想のプラン1 無効状態で追加、ワーカースレッドで自動有効化するアイデア
- nunulkさん: 解決策2、無効化した状態でユーザー登録しておいて、制限範囲内であることが確認されたら有効化する、という逆の流れではダメだろうか。結果整合性を一定時間でチェックするデーモンとともに
- tinsep19さん: ユーザーは無効で追加し、有効化を組織の役割に、で良くない?ユーザー数ライセンスだが、個人ライセンスの付替行為が暗黙的に発生しているとし、アクティベート要求をキューで順次処理かな
B. 組織に所属するユーザー一覧というドメインオブジェクトをアイデア
- haazimeさん: 組織がなくてもユーザーが存在して良いんだったら、所属ユーザーみたいなオブジェクト(別集約or組織の値オブジェクト)に上限の監視をやらせるんですけどね。うーん、難しい。。。…
- azihsoynさん: createユーザーとjoin組織を分けるとかかなぁ
B.i ユーザー一覧を組織集約に持たせるアイデア
- @turanukimaruさん: 集合は集約なのか?ってマサカリを投げてみる話
- cactuaroidさん: 「集約の境界と整合性の維持の仕方に悩んで2ヶ月ぐらい結論を出せていない話」の感想
- 増田 亨.さん: 私なら組織オブジェクトが、現在の有効ユーザ数を持つようにするかな。 ユーザ追加できるか問合わせるメソッドを用意する。 組織オブジェクトは常にメモリ上に存在して、適切に状態が更新されると「仮定」する。 リポジトリをDBアクセスのラッパーではなく、メモリ上のオブジェクト操作と考える。…
B.ii ユーザー一覧を独立した集約にするアイデア
- yojikさん: シンプルにユーザと組織の間に「所属」集約を作るかなぁ (個人的には集約をトランザクション境界にするDDDのアイディアは全く納得してないけど、どちらにしろこのケースなら所属オブジェクトは必ず必要)
- 松岡さん: 結果整合性がどうしてもコストが大きくて、同一トランザクションでやりたい場合、という前提で考えてみました。 5. パターン3の拡張で、ユーザー数の制約を管理する別の集約を作る 企業の中に他集約の数に応じたカウントを持つのはやはり違和感があるので、その部分だけ切り出してしまう。(続)…
C. イベント駆動アーキテクチャの長期プロセス(サーガ)的なアイデア
読んでいてIDDDでいうサーガ(長期プロセス)のイメージなのかなと思いました。解釈が間違っていたらすみません💧
- かとじゅんさん: 集約の境界と整合性問題に関する感想のプラン2 組織ID毎のワーカーがユーザー追加を担当することで2つのプロセスが同時並行に同じ組織へユーザーを追加しないことを担保するアイデア
D. オーバーがNGなら逆に現在ユーザー数を先に増やすアイデア
- morikuniさん: ユーザーの作成を先に行い、結果整合性でカウントを更新すると定員オーバーする可能性がある。ならば先にカウントを更新し、結果整合性でユーザーの作成を行えばいいんじゃないだろうか。 トレードオフはあると思うけど、カウントの整合性を第一にするなら選択肢としてはありそう。…*1
解決策3 アンチパターンではあるが集約間の整合性維持のためトランザクション制御を用いる
こちらは解決策1と比べて一定の言及はあるものの、やはり積極的な支持はほぼありませんでした。
解決策4 ユースケースの見直しによる再モデリングタイプのアイデア
2番目に多かったのがこのタイプです。 ユースケースの見直し、具体的にはユーザー上限ルールの稀な逸脱を許容する方向のアイデアが多かったです。
- katzchangさん: 契約ルールの方を柔軟にするかなぁ。固定金額契約でも、多少のオーバーは運用上許容してるサービスは多い印象がある。リアルタイムにbanする必要はないので、定期チェック機構を組織のほうに作るとよさそう。
- 増田 亨.さん: 完全に防ぐのは難しいですね。 大規模に並列で運用しているところは、厳密な制限の作り込みよりも、ある程度の不整合を許容する前提で設計と運用する方向を模索しているように思います。 事後チェックとか。…
- こむさん: 一般論なら結果整合で、 (略) 同時追加なんてそんな無いはずと信じて31人になるくらいは雑に許容してしまう、かな。
- 認定ジャバさん: 契約ルールの方を柔軟にするかなぁ。固定金額契約でも、多少のオーバーは運用上許容してるサービスは多い印象がある。リアルタイムにbanする必要はないので、定期チェック機構を組織のほうに作るとよさそう。
僕の現時点での回答らしきもの
まず解決策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 案と比べていくつかのメリット・デメリットがあります。
- メリット
- デメリット
- 相互参照は注意して扱わないと簡単に整合性が取れなくなってしまう*7
- 例: 有効な状態のユーザーをユーザーエンティティの組織IDだけ別の組織へ変えてしまうと、元の組織にその組織へ属さないにもかかわらず有効登録されたユーザーIDが残ってしまう
- 相互参照は注意して扱わないと簡単に整合性が取れなくなってしまう*7
また特筆するべき困った点が④を実行前にこのアプリケーションやコンテナが死んだ場合で、B.i案とは違い先に作られたユーザーは実際に組織へ無効状態で追加されています。 そのため、他の単純に無効化されているユーザーと区別することが難しく、リカバーにより手間がかかると感じました。
まとめ
というわけで、一旦現時点では解決策2 B.i案の「ユーザー一覧を組織集約に持たせるアイデア」が良いと思いました。 むろんこれが正解だという確信はまったくありません。細かい条件の違いで如何用にも最適解は変わりますし、僕が説明されていただいたシチュエーションでも2 B.i案が本当に最良か、試してみないことにはわかりません。
頂いたアイデアのおかげで考えがまとまりました。 一人で考えるだけではここまで早く良い案には辿りつけなかったと思います。 本当にありがとうございました。あとは実際にやってみて出した案が本当に良いか確かめようと思います。
2018/12/12 追記
なるほど理解できました。ドメインの不変条件・制約条件とその前提をどう折り合いつけるかという話だったんですね。
— cactuaroid (@cactuaroid) 2018年12月11日
if文の箇所を組織.ユーザー追加可能か()に置き換えるのと、組織.有効状態でユーザーを追加()内部でユーザー数上限チェックをすると良さそうに思います。
cactuaroid(@cactuaroid)さんから上のような指摘がありました。 それと、ファクトリによりIDがcreate時に発行される前提で書いたコードが以下です。 もっとモナドらしい自然な書き方ができると思うんですが、慣れていないので愚直な書き方で失礼します。
ユーザーはsaveできたが組織インスタンスが何らかのエラーでsaveできなかったときのために①を書いていますが、これを省くと更にコードが完結になります。 ただ、その際はそうやって発生する不整合状態を復帰させる(どの組織にも紐付かないユーザーを削除する)ようなデーモンないし仕組みが欲しくなるでしょう。
2019/01/20 追記
AUTO_INCREMENTだと先にユーザー集約を保存しないとIDが決定できない。組織での更新が失敗すると手動でロールバックする。AUTO_INCREMENTやめて事前ID採番やって、ユーザー集約本体を保存せずに、組織を更新したらよいです。失敗したら欠番になるだけでロールバック処理は不要です。
— 加藤潤一(かとじゅん) (@j5ik2o) 2019年1月18日
https://twitter.com/j5ik2o:かとじゅんさんから上のようなアイデアをいただきました。 (僕が勘違いをしていなければ)そのアイデアを元に書いた疑似コードが以下です。
cactuaroidさんから頂いたアイデアのコードと比べると、集約の保存の順番が ユーザー集約 → 組織集約 から 組織集約 → ユーザー集約 に変わっています。 楽観的ロック的に考えると組織集約の更新は同時に同じ集約へ更新が走ると衝突する可能性が一定あるのに対してユーザー集約の新規保存は衝突する可能性が(ほぼ)ありません。 そのため、仮で作ったユーザーを削除する必要がないという点で優れていると言えます。
一方で、組織集約の更新直後にアプリケーションがkillされたりDBクラスタがダウンしたりする可能性を考慮すると少し厄介です。 組織集約のユーザーID一覧に実際には存在しないユーザーのユーザーID(以後ゴーストユーザーID)が残ってしまうため、集約を参照する側はユーザーID一覧内のユーザーIDが実在しない可能性をコード中で考慮する必要が出てきます。 また、そのようなゴーストユーザーIDを定期的にクリーンアップする必要が出てきますがこれは2018/12/12の案での組織に紐付かないユーザーを削除するクリーンアップ処理に対応します。
※ すみません、ここら辺の結果整合性とシステムダウンの関係と復帰のさせ方については勉強不足かつ考え不足で自分の言葉にできていないので、後日また記事を書いて言葉にしようと思います。