どうも、かとじゅんです。
松岡さん(id:little_hands)が以下の記事を更新されたそうです。松岡さん自身が悩まれた中で検討したオプションであって、唯一の正解ではないと踏まえたうえで、率直な感想を述べたいと思います。結論からいうと、論旨は前回の記事と変わりませんが、コード例で具体的な考え方を示している点を工夫しています。
前回の考察記事も古くなったので、最新の記事に併せて考察をまとめ直したいと思います。
ドメインモデル
ドメインモデル図が追加されていますね。以下の3つの集約があるそうです。「一つの集約にまとめればいいよね」という提案はなしという前提で考えます。
- ユーザー
- タスク
- アクティビティ・レポート
「アクティビティ・レポート」は「タスク」もしくは「ユーザー」に関連を持つようです。
「これらのモデルをどう使いたいのか」が知れるともっと有益な議論ができそうです。ユースケースがないと使えるモデルかどうか判断できないと思います…。
主なユースケースは想定で考えるなら以下とか?ドメインの振る舞いにフォーカスするために、意図的に「確認」もしくは「閲覧」するユースケースは省略しました。「アクティビティ・レポート」はユーザーが作るものではなく、システム内部で作られるものですよね、きっと。
- ユーザーがユーザーアカウントを更新する
- システムがユーザアカウントのアクティビティ・レポートを追加する
- ユーザーがタスクを作成する
- システムがタスクのアクティビティ・レポートを追加する
このユースケースではCRUD臭しかしないので、もう少しモデルが成立するルールみたいなものがあれば面白いのですが…。
以下のような集約ルートになるのでしょうか(コードはScalaです。def
がfun
に変わったぐらいなので読めると思います)。
case class User(id: UserId, name: UserName, ...) { // ... } case class Task(id: TaskId, name: TaskName, userId: UserId, ...) { // ... } case class ActivityReport(id: ActivityId, taskId: Option[TaskId], userId: Option[UserId], ...) { // ... }
アクティビティ・レポートに対する違和感
何度かアクティビティ・レポートという名前を声に出してみる。文字で入力してみる。ActivityReport
という名前はActivityReport = Activity + Report
に分解できそう。Report
は何かしらのレポート形式を採用するのかもしれない。Activity
をReport
するのだからActivityReport
の前にまずActivity
という概念はありそうだが…。それともActivityReport
ではなくActivity
という名前の方が適切?
こういうふうに、ボケとツッコミでいうと、ツッコミをうまく使ってモデリングを深掘りしていきます。モデルの解釈を広げていくときはボケをうまく使う必要がありますが、今回はツッコミを入れてみました。このあたりの話は勉強の哲学にいろいろ書いてあるので参考にしてみてください。
- 作者:千葉 雅也
- 発売日: 2020/03/10
- メディア: Kindle版
Activity
ならドメインに関連してそうだが、ActivityReport
になると急にビューに見えてきます…。頭の中でこれはActivity
だと思って話しを進めます(ところで、Activity
って何?おそらく過去に起こった出来事?。ドメインイベントに似てますね…)。
それにしても、ActivityReport
のtaskId
, userId
はどちらか一方しか使われないわけで、微妙ですね…。別々の型や集合に見える。TaskActivity
やUserActivity
のほうがわかりやすくないか。”なぜわかりやすいかどうか”は多分にユーザーの関心や利害に結び付きそう。 まずそれを知りたい…。
などと、この時点で「そもそも論」を展開すると、本題に入れないので、このモデルでいいことにして話を進めします(笑)。
実装方法1. ユースケースで複数集約を更新する
「複数集約を1ユースケースで更新して良いのか?」について
ユースケースはある機能を一連のプログラム(式次第のイメージ)として表現するものと理解しています。そのために、要件によっては複数の集約を呼び出すこともあるで、この件は問題がないという認識。というか、僕はこれ自体は否定はしていません。むしろ複雑なユースケースでは複数の型の集約を利用する必要があります。 なので、このような実装になるだろうと思います。
ただし、複数集約の更新を同一トランザクションに含めるかについては異論があります。集約の中では強い整合性(RDBのトランザクションなど)を、外では弱い整合性(結果整合性)を使うべきだからです。これは前回のブログ記事に書いた通りです。
メリット・デメリットについて
- メリット
- 特に異論はないです。
- デメリット
- 「アクティビティ・レポート」を「更新し忘れる」の件
- 仕様を満たしていないプログラムなのでバグということかと思います。まずはそれをテストや表明で対策できないか。もちろん型で表明できればベストですがやり過ぎると代償もあるわけで。これについては他の実装方法と併せて後述します。
- うっかり「更新し忘れる」以外のデメリットがあります(基本的に前回の記事で書いた通りです)
- 強い整合性の境界を持つ集約よりも、外側に二重に強い整合性の境界(Transactionalアノテーションで囲っている範囲)を設けているという点です。本来は集約とトランザクションの境界が一致していることが望ましいのですが、以下のようなユースケース毎に強い整合性の境界が設定されています。ユースケースの内側に張り巡らされた、集約の枠を飛び越えた強い整合性を基にロジックが組まれることになります…。集約はそれ単体で整合性が独立するはずですが、この設計では独立していないことになってしまうわけです。このような環境下では、二つの集約の関係は密結合になるリスクもあります。というのがDDDとしての立て付けだと思います。
- 個人的には、今回のケースではRDB以外はない想定でしょうしTransactionalアノテーションをつけても大きな実害はなさそうです。DDDの集約の考え方に沿うなら結果整合でユースケースを実装するべきですが、もちろんそれ以外の設計の選択はあります*1。つまるところ、どんな選択をするにしても設計(How)のコンテキスト(Why)に納得感があればよいのではないでしょうか。とはいえ、このように集約とは異なるものを「集約」と呼ぶのはいかがなものかと思っています。本来の集約とは違う解釈の「集約」が、割れ窓理論的に捉えられてしまうリスクを懸念します。
- 強い整合性の境界を持つ集約よりも、外側に二重に強い整合性の境界(Transactionalアノテーションで囲っている範囲)を設けているという点です。本来は集約とトランザクションの境界が一致していることが望ましいのですが、以下のようなユースケース毎に強い整合性の境界が設定されています。ユースケースの内側に張り巡らされた、集約の枠を飛び越えた強い整合性を基にロジックが組まれることになります…。集約はそれ単体で整合性が独立するはずですが、この設計では独立していないことになってしまうわけです。このような環境下では、二つの集約の関係は密結合になるリスクもあります。というのがDDDとしての立て付けだと思います。
- 「アクティビティ・レポート」を「更新し忘れる」の件
「実装方法2. ユースケースで複数集約を更新する」や「実装方法3. ドメインイベントを使用する」について
結論からいうと、さじ加減が難しいところですが、実装方法2も実装方法3も「更新漏れを防ぐ」ために余計な複雑さを導入してしまっていると思います。
実装方法1であっても、以下の方法で正しさを検証すればよいのではないでしょうか
- テストで振る舞いが正しいことを検証する
- ユースケースのexecuteメソッドの事後条件を表明する
もちろん、この場合でもテストや事後条件自体が間違っていたらどうするのか。テストが正しくても仕様が間違っていたらどうするのか、仕様が正しくても要件が間違っていたら…などと無限に続きます。松岡さん提案の方法でも、TaskCreateParameter
を間違って実装して利用すると、結局期待した正しさを得ることができません。
つまるところソフトウェアのおける、正しさとは相対的な概念なのです(出典 オブジェクト指向入門 第11章 契約による設計:信頼性の高いソフトウェアを構築する)。正しさの証明は本来際限がないので、コストとリターンの均衡が取れる適当なところで絶妙に妥協する必要があります。ドメインオブジェクトの型として間違った使い方ができないように考えることは重要なことですが、そのために複雑なコードを組み込んでしまうぐらいなら、立ち止まってテストや表明の範囲に留めます。
オブジェクト指向入門 第2版 原則・コンセプト (IT Architect’Archive クラシックモダン・コンピューティング)
- 作者:バートランド・メイヤー
- 発売日: 2007/01/10
- メディア: 単行本(ソフトカバー)
まぁ確かによいアイデアが思いついて、勢いでリファクタリングすることもありますが、そういうときほど、マクベス症候群(出典 レガシーソフトウェア改善ガイド 4.1 規律あるリファクタリング)を発症しないように、一度冷静になった方が良いと思っています。
レガシーソフトウェア改善ガイド (Object Oriented Selection)
- 作者:クリス・バーチャル
- 発売日: 2016/11/11
- メディア: 単行本(ソフトカバー)
「実装方法3. ドメインイベントを使用する」についても、「更新漏れを防ぐ」ことと天秤にかけてバランスできるでしょうか。僕はそう思えません。Event Sourcingをシステムの耐障害性やスケーラビリティを確保するために導入するなら、ドメインイベントを採用する価値はありそうですが…🤔
じゃぁどうするの
「更新漏れを防ぐ」は上記の方法でバランスをとることにして、ユースケース部分をどう実装したら良いかを考えてみました。大袈裟な仕組みを導入しないでも結果整合にする方法はあるという話です。
ユースケースをRDBのトランザクション境界にしない
ユースケースのexecute
は同一トランザクションに含めずに、以下のようにします。(1),(2)はそれぞれ独立したトランザクションになります。
class CreateTaskUseCase1( taskRepository: TaskRepository, activityReportRepository: ActivityReportRepository, ) { def execute(taskName: String): Unit = { val task = Task(taskName) taskRepository.store(task) // (1) リポジトリ内でトランザクションが閉じる val activityReport = ActivityReport(task) activityReportRepository.store(activityReport) // (2) リポジトリ内でトランザクションが閉じる } }
想定問答集は以下です。
- Q1, (1)→(2)の順に処理し(2)が失敗したらどうするの?
- 普通に 呼び出し元にはエラーを返します。クライアントにもエラーが返ります。
- Q2,
Task
だけ保存されるとTask
とActivityReport
を結合するクエリがおかしくなるのでは?Task LEFT JOIN ActivityReport
だとおかしくなりますよね。 (1)→(2)の順序ならActivityReport LEFT JOIN Task
的なクエリをすれば問題はおきません。
- Q3, ユースケースは再実行すると同じタスクIDで
INSERT
するので失敗するのでは?- 同じタスクIDなら、
INSERT
orUPDATE
してください- つまりクライアントがユースケースを再実行できるようになっていないといけません。べき等性を担保する必要があります
- 都度ID生成するなら、ゴミ
Task
が残ることになります- 失敗時のゴミレコード vs 同一トランザクションによってロジックが密結合になるリスクのトレードオフになります
- 同じタスクIDなら、
結果整合の場合は、すべての更新が完了すれば結果的に正しい状態に収束します。更新中は中間状態が見えるかもしれません。これによってビジネス上大きな問題が起きないように想定しておく必要があります。
この考え方で実際に運用したことあるの?って話ですが、あります。認証/認可に関係する、とあるウェブアプリケーションもこの考え方で設計して数年運用していますが、実用上整合性に関する問題はありません。逆に何でもかんでもトランザクションに含め、ユースケースによってトランザクション境界がちぐはぐに異なるケースではいろいろな問題(一番ヤバいやつはデッドロック…)が起きることを体験しています…。
Taskを更新するユースケースでは
今回の場合は新規追加なのでレースコンディションは起こらないですが、既存の集約を更新するユースケースも考えてみます。
class RenameTaskUseCase1( taskRepository: TaskRepository, activityReportRepository: ActivityReportRepository, ) { def execute(taskId: TaskId, taskName: TaskName): Unit = { val task = taskRepository.findById(taskId) // (F1) val renamedTask = task.withTaskName(taskName) taskRepository.store(renamedTask) // (S2) val activityReport = ActivityReport(task) activityReportRepository.store(activityReport) // (S3) } }
上記のコードでは以下のようにAとBの並行処理がある場合、二つの操作に分離性や独立性がないので、期待した結果にはなりません。
(A-F1) -> (A-S1) -> (B-F1) -> (B-S1) -> (B-S2) -> (A-S2) -> ...
これはロックを導入すれば解決できます。クライアントサーバ型のシステムでは、SELECT ... FROM ... FOR UPDATE
で悲観的ロックを利用することがありますが、入力→確認→更新するようなウェブのユースケースではリクエストを跨がってトランザクションを保持し続けるには無理があります。ということで、ウェブでは楽観ロックを使うことが多いです。
class RenameTaskUseCase1( taskRepository: TaskRepository, activityReportRepository: ActivityReportRepository, ) { def execute(taskId: TaskId, taskName: TaskName, taskVersion: TaskVersion): Unit = { val task = taskRepository.findById(taskId, taskVersion) // (F1) val renamedTask = task.rename(taskName) taskRepository.store(renamedTask) // (S2) val activityReport = ActivityReport(task) activityReportRepository.store(activityReport) // (S3) } }
上記の場合は、BのF1はバージョンがすでに更新されているのでTask
が取得できない、もしくはBの(S2)でバージョンが進んでいるため保存に失敗するので、ユースケースの処理はエラーになりデータの不整合を防げます。 (S3)も楽観ロックを提供してもよいですが、常に追記になるなら不要でしょう。
RDB非依存の良さ
前述したように、ユースケースをRDB非依存として設計しておけば、リポジトリのストレージがRDBでもNoSQLでもよくなります。別のマイクロサービス上に存在する集約を呼び出すことも可能です。今どきの分散するシステムにおいては、RDBのトランザクションは一つの選択肢ですが、あらゆる局面で使えるわけではありません。
逆を言えば、RDBしか使わないシステムなら上記は考慮せずに、ユースケースをRDBのトランザクション境界にしても問題ないでしょう。えっ、ほんとに?分散キャッシュを使いたいとかSQSを使いたいとか後から言わないでよ?って意味になります。
実際、後からRedis, Memcachedなど分散キャッシュや、SQSやKafkaなどのメッセージング基盤などを必要することはよくあります。レビューで実際あったのは、RDB前提になってしまったユースケース・ロジックに、以下のようなコードを追加された事例がありました…。まぁキャッシュだけ残ることありますよね…。読者のみなさんはこんな間違いをしないと思いますが。RDBは便利なのですが、このケースでは使い方が間違っていると言わざるを得ません。前述したように結果整合の考え方でユースケースを書き直したほうが無難です。
class RenameTaskUseCase1( taskRepository: TaskRepository, activityReportRepository: ActivityReportRepository, ) { @Transcational def execute(taskId: TaskId, taskName: TaskName): Unit = { val task = taskRepository.findById(taskId) val renamedTask = task.rename(taskName) taskRepository.store(renamedTask) // (1), キャッシュとしてRedisに書き込んでいる。 // (2)でロールバックしてもRedisに永続化されたデータはロールバックできない… taskCacheRepository.store(renamedTask) val activityReport = ActivityReport(task) // (2), DB I/Oエラーが発生しロールバックする activityReportRepository.store(activityReport) } }
(蛇足) アクティビティ・レポートはビューモデル?
言いたいことは以上なのですが、論旨が微妙にずれるアクティビティ・レポートについての考察です。蛇足なので時間がある方のみどうぞ。
基本的な考え方は上記と変わりませんが、アクティビティ・レポートのモデルについて考えてみました。
アクティビティ・レポートがどんな属性を持つかわからないのですが、ユースケースが閲覧のみならば、アクティビティ・レポートはビューモデルやリードモデルに見えます。冒頭でもいったように出来事(ドメインイベント)をうまく使えないか。集約内部で発生した出来事(ドメインイベント)をモデリングしてはどうか。振る舞いの結果は新しい状態を表現するインスタンスとドメインイベントを返すようにしました*2。そして、ドメインイベントからアクティビティ・レポートを生成できればよさそうな気がします。まぁ機能的なロジックとしてはほぼ変わらないですが、アクティビティ・レポートにビューの知識があるならこのようにして分離することも可能ではという話です。
case class Task(id: TaskId, name: TaskName, userId: UserId, ...) { def rename(value: TaskName): (Task,TaskRenamed) = (copy(name = value), TaskRenamed(...)) def complete: (Task, TaskCompleted) = (copy(status = Completed), TaskCompleted(...)) } object Task { def apply(id: TaskId, name: TaskName, userId: UserId, ...): (Task, TaskCreated) = { (Task(...), TaskCreated(...)) } } sealed trait TaskEvent { def id: TaskEventId def taskId: TaskId } case class TaskCreated(id: TaskEventId, taskId: TaskId, ... ) extends TaskEvent case class TaskAssigned(id: TaskEventId, taskId: TaskId, assignerId: UserId, assigneeId: UserId, ... ) extends TaskEvent case class TaskRenamed(id: TaskEventId, taskId: TaskId, taskName: TaskName, ... ) extends TaskEvent case class TaskCompleted(id: TaskEventId, taskId: TaskId, ..., createdAt: Instant ) extends TaskEvent class CompletedTaskUseCase1( taskRepository: TaskRepository, activityReportRepository: ActivityReportRepository, ) { def execute(taskId: TaskId, taskName: TaskName, taskVersion: TaskVersion): Unit = { val task = taskRepository.findById(taskId, taskVersion) val (completedTask, taskCompleted) = task.complete taskRepository.store(completedTask) taskEventRepository.store(taskCompleted) } } class ActivityReportDao { // ActivityReportはリードモデルとして扱う def findByTaskId(taskId: TaskId): Seq[TaskActivityReportDto] = { // TaskEventが保存されているテーブルからSELECT ... FROM ... JOIN ... するとか、 // TaskEventを基に別のデータを作成し、それをクエリで返すとか、方法はいくつかあります } }
TaskEvent
, UserEvent
のように型として分けるのか、Event
としてまとめた型にするのか、そのあたりはよく分かりませんが、この設計だとTask
のステートとTask
のイベント(一つの集約と見なせる)をダブルライトすることになりますね。ここは妥協になってしまうかと。
Event Sourcingでは
ちなみに、Event Sourcingではステートはイベントから導出できるのでイベントしか書き込みません。以下のようなイメージになりますが、このままだといろいろ問題あります。(1)(2)で長大なtaskEvents
をリクエスト毎読み込むとパフォーマンスに相応のペナルティがあります。(3)の楽観ロックがないと(4)は無条件に追記されてしまう。これらの問題はAkkaなら簡単に解決できます。
前述したように、システムの耐障害性やスケーラビリティを確保する必要があるならEvent Sourcingは投資対効果がバランスできるかもしれませんが、そういう目的がないなら手段として見合わないと思いますが、考え方は参考になると思います。
class CompletedTaskUseCase1( taskRepository: TaskRepository, activityReportRepository: ActivityReportRepository, ) { def execute(taskId: TaskId, taskName: TaskName, taskVersion: TaskVersion): Unit = { val taskEvents = taskEventRepository.findById(taskId) // (1) val task = Task.fromEvents(taskEvents) // (2) val taskCompleted = task.complete(taskVersion) // (3) task内部のversionと引数のversionが一致するならcomplete状態に遷移できる taskEventRepository.store(taskCompleted) // (4) } }
まとめ
ということで、以下まとめです。
- 今回のモデル構造自体に異論はない
- ユースケース内で複数の集約を利用することは問題ない。というか必要
- 「更新し忘れ」対策はほどほどに
- 原則はユースケースは弱い整合性の境界、集約は強い整合性の境界。前回書いた記事の主張どおりです
- それほどコストをかけずにユースケースを結果整合にする方法はある
ご参考までに。
補足: 3/22
この記事に書いた考え方はマイクロサービスとかやる人向けですと言われることがあります。それはそのとおりですが、そんなにマイクロサービスってハードル高いですか?クラウドとDevOpsで簡単になったはずですよね。身近なものですよ。
ということで、何か参考になる書籍を教えてほしいと言われたので以下の本がオススメです。
「5.2 DDDのAggregateパターンを使ったドメインモデルの設計」で紹介されている「ルール3: 1つのトランザクションで1つのアグリゲートルートを作成または更新する」をぜひ読んでください。この記事と寸分違わない解説がなされています。
マイクロサービスパターン[実践的システムデザインのためのコード解説] impress top gearシリーズ
- 作者:Chris Richardson,長尾高弘,樽澤広亨
- 発売日: 2020/03/23
- メディア: Kindle版