複雑なドメインに立ち向かうための Event Sourcing と Hybrid-CQRS の採用事例

私のチームでは Event Sourcing と Hybrid-CQRS を採用してプロダクトを開発しています。なぜ Event Sourcing、Hybrid-CQRS を採用したのか、実際に開発してみてどうだったのかを共有することで、なにか参考になるところがあればと思います。

なぜ Event Sourcing と Hybrid-CQRS なのか

私の開発するプロダクトでは、多くのデータがバージョンを持ち、異なるバージョンへの結びつきがあるなど、仕様が複雑になることが予想されていました。データをどのように表現するか検討したときに、異なるバージョンが入り組んだデータを 1 つのデータモデルで Write と Read を兼ねるのは複雑すぎると判断し、データモデルを分けることにしました。
データモデルを分割する際に、同一のデータストアを使用しながらコードレベルでオブジェクトを分割するか、データストアに永続化する形式自体を変更するかをまず検討し、データストアに永続化する形式自体を変更する方法を選択しました。理由としては、同一の表現形式を使用した場合に、データモデルの表現力が使用するデータストアに制限されることを避けるためです。
次に、永続化する形式自体を変えた際に、Write 側と Read 側の整合性が取れていることをいかに保証できるかを検討し、別々のモデルとして管理するのではなく Read 側のモデルは Write 側のデータを再生したものであるとすることで整合性が取れるようにしました。
これらは具体的には、バイテンポラルデータモデル、履歴テーブル、Event Sourcing を比較検討し、Event Sourcing を採用した形です。
Write 側のデータモデルは表現力を重視しオブジェクトで表現し、Read 側のデータモデルは GraphQL を採用していたのもあり GraphQL と相性がよさそうだとして GraphQL のオブジェクトを RDB のテーブルと非正規化して対応する形で表現することにしました。
これにより、データモデルを分けることによる関心の分離、オブジェクトの表現力の向上、過去の任意の時点の状態を再現可能、履歴のデータの保証、などプロダクトと相性がいい選択ができたと思います。

Event Sourcing を採用した場合、Read のたびにドメインイベントから集約を構築してレスポンスを構築していてはパフォーマンスが出ないため、Read 用の Read Model を作成することになります。ここのモデルが分かれていることにより、データストアを分割することが可能になり、Write と Read それぞれの要件に適したデータストアを選択できるようになります。
現在は CQRS という言葉が指す意味が厳密には定まっていないと感じていますが、原義的にはデータストアを分けているものが CQRS だと私は理解しています。
それに対して、同一データストア内でモデルを分ける Hybrid-CQRS という言葉があります。Hybrid-CQRS のメリットとしては、インフラのシンプル化や、CQRS と比較した際の相対的な採用する技術の少なさによる学習コストの低減などがあります。
私のプロジェクトでは、リリースする速度を優先したかったためインフラ構成はできるだけシンプルにしたいというモチベーションがありました。また、Event Sourcing を採用した実際のシステム開発は私にとっても初めてだったということもあり、学習コストが高すぎないようにしたいという思いもありました。
以上のことから CQRS ではなく Hybrid-CQRS を採用することにしました。

Hybrid-CQRS を採用した場合、同一データストア内にデータがあるため、1 リクエストとトランザクションを対応させるという選択肢も生まれます。ここでは結果整合を保つためのさまざまな処理を省略するために 1 リクエストとトランザクションを対応させるという選択をしました。
しかし、後々のためのスケーラビリティも確保したかったので、マルチモジュール構成を採用して依存関係を制限し、モジュールで制限できないものは ArchUnit で制限をかけることにしました。

この構成によって得たもの、失ったもの

個人的な感覚として、最大の収穫は複雑性の分割による認知負荷の低減でした。
仕様が複雑になることは予想していましたが、わかっていても実際に開発しているとやっぱり難しいと感じる日々でした。実装時の関心やコードの影響範囲を絞ることによって複雑なドメインでも対応できたと感じています。
このような複雑なドメインにおける複雑性の分割は重要度を高めに考えることが大事で、手段としてデータモデルの分割があり、具体的な実装として Event Sourcing があると考えています。

データモデルが分かれているので、テーブルのリファクタリングや機能追加が無停止で行えるようにもなりました。
過去のイベントを再生して新しい Read Model を構築しておき、フィーチャーフラグを切り替えることで実現しています。システムを停止せずに機能追加などが行えるため、通常の業務時間にリリースできることは運用の負担を減らしてくれました。

前述した 1 リクエストとトランザクションを対応させたことで得たものとしては、かなり楽ができました。
トランザクション内は強い整合性になるため、ロジックが間違っていない限りは Read Model Updater で構築されたテーブルまでそのまま信用できます。データを信用することでさまざまな確認や整合性のためのコストを省けました。
もしトランザクションを分離していたら、当初希望していたスケジュールに間に合わなかった可能性が大きいなと感じています。

しかし失ったものとしては、スケーラビリティを獲得するアーキテクチャへの移行コストがあります。
将来的なスケーラビリティのためにマルチモジュールの導入、トランザクションの分離に備えて内部の実装ではドメインイベントを Pub/Sub の構成にする、などを行いましたが、結局現時点のシステムが同一トランザクションで動いているので、暗黙的な依存が生まれているかどうかを保証できません。おそらく生まれてしまっているだろうと思います。
ここからスケーラビリティを獲得するためには、もしかしたら作り直しに近いコストがかかるかもしれません。トランザクションを分離するコストをかけることでスケーラビリティを獲得できた可能性を考えると、トランザクションを分離しないことで楽できた分と比較して、このトレードオフが見合うかどうかは慎重に検討する必要があると感じました。

また、Event Sourcing は State Sourcing とはパラダイムが異なるため、メンバーによってはキャッチアップに時間がかかるかもしれません。

個人的な学び

認知負荷の低減は私にとってとても大きなものでした。
CQRS/ES を採用した場合アーキテクチャとしての複雑度は上がりますが、コードの複雑度は下がると感じています。現実が複雑なことには変わりがなく、どこでその複雑さを表現するかという選択だと思います。
データをそのまま State Sourcing するよりは初期の実装コストはかかるかもしれません。ただ私は Software Engineering を「動くものを作ること」ではなく、「動くものを作り続けること」だと考えています。
このようなアーキテクチャは前に進み続けるための選択肢の一つだと考えています。

今後も機会があれば CQRS/ES を積極的に採用していきたいです。
次は最初からスケーラビリティも獲得できるように、引き続き学習を続けていきます。

参考リンク

Event Sourcing と CQRS については下記のリンクによくまとまっています。

具体的な実装方法については下記のリポジトリが参考になります。