LoginSignup
7

More than 3 years have passed since last update.

CQRS+ESで大事なのはコマンド部(イベント)とプロジェクタなのでは、という話

Posted at

はじめに

EventStoreを用いたCQRS+イベントソーシングの実践と考察の続きで設計していたところ、CQRS+ESを真に体感できたためその内容について投稿します。(当たり前過ぎたらすいません。)

自分的CQRS+ESにたどり着くまでの流れ

全部リポジトリ経由

はじめにDDDを学んだときは、集約はリポジトリ経由で保存と取得をすべきと考えていました。
image.png

このときは各画面特有の情報の固まりをどうやって取得しようかと悩みました。

  • 必要な集約を全部取得して画面に必要な部分だけ使う(余計な情報も取得してる)
  • 集約にGet専用なプロパティを追加しちゃう(SQLがjoinだらけでカオス)

CQRS

コマンドとクエリを分離するべきと考えを改めました。
image.png

これで取得部はかなり自由になり楽になりました。
保存は前回のまま集約単位で保存していました。

CQRS+ES

格納先を物理的に分けてみようと思いEventStoreを導入してみました。
EventStoreを用いたCQRS+イベントソーシングの実践と考察の話です。
image.png

ここでも保存は前回と同じ集約単位での保存で考えていました。
なので橋渡し役は単純に集約のjsonを集約のDAOに変える程度と考えていました。

業務知識に発送システムを導入してみる

EventStoreを用いたCQRS+イベントソーシングの実践と考察の記事で以下のような業務知識を想定していたのですが

ウェブ上から本を借りるシステム(のイメージ)
集約は利用者と本。本のタイトルなどの情報は書籍として正規化しました。
本を登録する、本を借りる、本を延長する、本を返す、本を破棄する、というユースケースを想定

これに発送する仕組みを導入しようと考えました。DDDの例でよくある、"本"という集約でもシステムによって文脈(コンテキスト)が異なるときの話です。
image.png

コンテキストマップで表すと多分こんな感じ。点線は境界づけられたコンテキストです。

  • 貸借コンテキストの本はいつ借りられたかを気にしてていつ発送したかなんて気にしない
  • 発送コンテキストの本はいつ発送したかを気にしていていつ返却されたかなんて気にしない

なので
貸借コンテキストの本の集約は
image.png

発送コンテキストの本の集約は
image.png

と差異がでます。

CQRSに当てはめてみる

ここにきてようやく疑問が発生しました。
image.png

保存する際に特定の集約単位だとその他のシステムに情報を渡せないのです。(渡せるとしてもきつい)

イベントソーシングの意味を体感する

イベントソーシングとはそのままの意味で、イベントをソース(情報源)とする手法なんですね。
だからコマンド実行時にするべきは

"本を借りる"から本の集約全体を保存
ではなく
"本を借りる"結果として"本を借りた"イベントが発生したので本のIDと本の貸借期間を保存

なのだと。

ドメインイベントを定義する

今回想定した業務知識で発生するドメインイベントを列挙してみます。

  • Domain.RentalSubDomain.Events.User.AddedUserVer100(利用者を登録した)
  • Domain.RentalSubDomain.Events.User.LendedBookVer100(本を借りた)
  • Domain.RentalSubDomain.Events.User.ReturnedBookVer100(本を返した)
  • Domain.RentalSubDomain.Events.BookInfo.AddedBookInfoVer100(本を登録した)
  • Domain.RentalSubDomain.Events.Book.AddedBookVer100(本を登録した)
  • Domain.RentalSubDomain.Events.Book.LendedBookVer100(本を借りた)
  • Domain.RentalSubDomain.Events.Book.ExtendedBookVer100(本を延長した)
  • Domain.RentalSubDomain.Events.Book.ReturnedBookVer100(本を返した)
  • Domain.RentalSubDomain.Events.Book.DestroyedBookVer100(本を破棄した)
  • Domain.DeliverySubDomain.Events.Book.ShippedBookVer100(本を発送した)

各ドメインイベントに1対1で対応するDTOも定義しています。
例としてひとつあげると

F#
    module Book =
        [<Literal>]
        [<CompiledName "LendedBookVer100">]
        let lendedBookVer100 = "book.lendedBookVer1.0.0"
        type LendedBookDTOVer100 = 
                {
                    id: string
                    user_id: string
                    lending_start_date: Nullable<DateTime>
                    lending_end_date: Nullable<DateTime>
                }
                static member Create(a,b,c,d) = { id = a; user_id = b; lending_start_date = c; lending_end_date = d }

"本を借りる"というコマンドを実行すると

  • Domain.RentalSubDomain.Events.User.LendedBookVer100(本を借りた)
  • Domain.RentalSubDomain.Events.Book.LendedBookVer100(本を借りた)

の二つのイベントがイベントストアに保存されるようにしました。
これでコマンド部を状態ベースからイベントベースに変更できました。

橋渡し役はProjectorだった

Projection Building Blocks: What you'll need to build projections
Event Sourcing - Projections

で理解できたんですけど橋渡し役はProjector(投影機)と呼ぶらしいです。
自分的な解釈ですけど、唯一の真実であるイベントストアから(必要なイベントのみ)投影するから。
図にすると
image.png

こんな感じで、EventStoreから購読するんですけど購読する内容は自分のコンテキストで必要なイベントだけ。
貸借プロジェクタでは

            var x = _event.EventType switch {
                Domain.RentalSubDomain.Events.User.AddedUserVer100 => AddedUserVer100(_event),
                Domain.RentalSubDomain.Events.User.LendedBookVer100 => UserLendedBookVer100(_event),
                Domain.RentalSubDomain.Events.User.ReturnedBookVer100 => UserReturnedBookVer100(_event),
                Domain.RentalSubDomain.Events.BookInfo.AddedBookInfoVer100 => AddedBookInfoVer100(_event),
                Domain.RentalSubDomain.Events.Book.AddedBookVer100 => AddedBookVer100(_event),
                Domain.RentalSubDomain.Events.Book.LendedBookVer100 => BookLendedBookVer100(_event),
                Domain.RentalSubDomain.Events.Book.ExtendedBookVer100 => ExtendedBookVer100(_event),
                Domain.RentalSubDomain.Events.Book.ReturnedBookVer100 => BookReturnedBookVer100(_event),
                Domain.RentalSubDomain.Events.Book.DestroyedBookVer100 => DestroyedBookVer100(_event),
                _ => 0
            };

発送プロジェクタでは

            var x = _event.EventType switch {
                Domain.RentalSubDomain.Events.Book.LendedBookVer100 => await LendedBookVer100Async(_event),
                Domain.RentalSubDomain.Events.Book.ReturnedBookVer100 => ReturnedBookVer100(_event),
                Domain.DeliverySubDomain.Events.Book.ShippedBookVer100 => ShippedBookVer100(_event),
                _ => 0
            };

という感じ。
コンテキストの違うドメインイベントであっても必要であったら読み込みます。
例えば発送Appとしては"本を借りた"イベントを取得しないとなんの本を発送したらわからないので監視します。
逆に貸借Appでは"本を発送した"というイベントは必要ないので無視します。(貸借App上で発送状況は表示しない想定)

まとめ

やっとCQRS+ESの真の意味を理解できた気がします。(全然違ってたらすいません。。)

CQRSはひとつのDBでも有用なアーキテクチャですけど、マイクロサービス的な方向になるとひとつのDBでコマンドもクエリも行うのは厳しいかと思います。
かといって単純にコマンドとクエリを物理的に分けても状態ベース(集約単位)の保存のままだとクエリ元の生成が上手くいきません。

そのためのイベントソーシングという考え方で、クエリ元は情報を保存しているというよりは必要な情報を投影しているだけ。
こういう風に考えるとクエリ元のテーブルは集約単位である必要はなく、各画面に必要な情報を持ったテーブルにしちゃっても良い気がします。
(先に必要なレコードを作ってしまえばRDBである必要もないかも)

また、ドメインイベントの抽出の重要性についても改めて理解できました。
ユースケースから設計すると、~~するという文脈になってしまうのでコマンドとして設計してしまっていましたが
~~する結果として、~~した、というところを強く意識するようにします。

全然違うこと書いていたらご指摘願います。

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
7