Railsドメイン設計: イベントソーシングでCQRS Read Modelが基本的に必要な理由(翻訳)

概要

原著者の許諾を得て翻訳・公開いたします。

日本語タイトルは内容に即したものにしました。

用語

  • CQRS: コマンドクエリ責務分離(Command Query Responsibility Segregation)

参考: CQRS + ESについてのまとめ - 記録

  • CQRS Read Model: Query Modelとも呼ばれ、データの読み出しをデータの書き込みから分離します。CQRS Write Modelと対になります。

参考: The Read Model · Nick Chamberlain

Railsドメイン設計: イベントソーシングでCQRS Read Modelが基本的に必要な理由(翻訳)

イベントソーシング(Event Sourcing)は一定のメリットを得られる優れた技法ですが、1つ大きな制約があります。現在のステートの概念を簡単に得られないため、たとえば出荷可能数が10個以下の全製品が欲しいという問い合わせの回答を得るのが簡単ではありません。

ID=1ProductとしてイベントのProduct-1ストリームを読み出し、それを用いてこの製品の現在のステートを再構成して、その製品の出荷可能数が10個より少ないかどうかという回答を得ることも一応可能ですが、すべての製品について回答を得るには、すべてのProduct-*ストリームを列挙し、全製品の保存済みドメインイベントをすべて処理する必要があります。この操作には膨大な時間とコストがかかるでしょう。

普段の業務で目にする次のユースケースはいずれも困難が少し増すでしょう。

  • 登録ユーザーの最新10人を表示する
  • 顧客をメールか住所で検索する
  • 今月からの全トランザクション数
  • 顧客のライフタイムバリュー
  • 「blue pillow」というテキストを含む全製品

他にいくらでも考えられます。

どうしてこうなってしまうのでしょうか。

その理由は、エンティティ(Entity)や集約(Aggregate)がイベントソーシングされると、オブジェクトのリポジトリへの問い合わせ方法が1つに限定されてしまうためです。その方法とはすなわちfind_by_idです。

他の場所(他のエンティティなど)で得たこのidが、その場所への参照(またはUIからの参照、APIからの参照、リクエストからの参照)を持つことは理解できるので、次のように書けます。

id = params[:id]
product = ProductRepository.find_by_id(id)

このリポジトリは、自分が読み取るべきイベントのストリーム(Product-1など)を認識し、それらのイベントがProductのインスタンスに適用されることで1つの製品の現在のステートが再構成されます。以上。

では上述のユースケースをすべて解決する方法があるとしたらそれは何だかおわかりでしょうか?それがRead Modelです。

e-コマースアプリの製品リストを顧客に表示し、AddToBasketProductなどのコマンドをイベントソーシングしたいのであれば、ProductのRead Modelが1つ必要になります。このRead Modelは個別の要件次第で、ElasticsearchやSQLなどどんなデータベースにも配置できます。

あるRead Modelを手順に沿って構成するにはどうしたらよいでしょうか。

  1. 製品の更新は、新しいドメインイベントを保存することで行う
  2. イベントハンドラがトリガされる
    • イベントハンドラの保存後、イベントにプッシュしたメッセージキューでトリガ可能になる
    • 最もシンプルなケースはActiveJobで実装可能、もっと複雑なシナリオではKafkaやRabbitやAmazon SQSで実装可能
    • または別プロセス(プロジェクション)が保存済みドメインイベントを定期的にイテレーションして処理対象を選ぶ
    • ドメインイベントの保存にEventStore DBを用いた場合は非常にシンプルに行える
  3. イベントハンドラは、発生するイベントと処理するドメインイベントの種類に応じてそのRead Modelを更新する

例を1つご紹介します。

ProductRegisteredイベントは、ProductListというActiveRecordベースのRead Modelへの新しい要素の追加を発生させます。

ProductList.create!(
  id: event.data[:product_id],
  name: event.data[:name],
  price: BigDecimal.new(event.data[:price]),
)

ProductPriceChangedイベントは、リスト上の価格の更新を発生させます。

ProductList.
  find_by!(id: event.data[:product_id]).
  update_attributes!(
    price: BigDecimal.new(event.data[:price]),
  )

他にいくらでも考えられます。

そして価格の高い製品上位10種を表示したい場合は、このProductListというRead Modelに基づいてアプリの読み出し側で行えばよいのです。

ProductList.order("price DESC").limit(10)

アプリの書き込み側では、イベントソーシングされているProductクラスの書き込みで変更をトラッキングしつつビジネスルールを保護します。書き込み側は、ProductRegisteredProductPriceChangedを公開する責務を持ちます。

お知らせ: もっと詳しく知りたい方へ

イベントハンドラやRead Modelやイベントソーシングについてもっと詳しく知りたい方は、私たちの近刊「Domain-Driven Rails ebook」をぜひ手にお取りください。

関連記事

Ruby/Railsのプロ開発者としての5年間を振り返る(翻訳)

デザインも頼めるシステム開発会社をお探しならBPS株式会社までどうぞ 開発エンジニア積極採用中です! Ruby on Rails の開発なら実績豊富なBPS

この記事の著者

hachi8833

Twitter: @hachi8833、GitHub: @hachi8833 コボラー、ITコンサル、ローカライズ業界、Rails開発を経てTechRachoの編集・記事作成を担当。 これまでにRuby on Rails チュートリアル第2版の半分ほど、Railsガイドの初期翻訳ではほぼすべてを翻訳。その後も折に触れてそれぞれ一部を翻訳。 かと思うと、正規表現の粋を尽くした日本語エラーチェックサービス enno.jpを運営。 実は最近Go言語が好き。 仕事に関係ないすっとこブログ「あけてくれ」は2000年頃から多少の中断をはさんで継続、現在はnote.muに移転。

hachi8833の書いた記事

週刊Railsウォッチ

インフラ

ActiveSupport探訪シリーズ