Open5

CQRSを理解する

やまやま

ドメイン駆動設計(DDD)やコマンド・クエリ責務分離(CQRS)を勉強中です。
CQRSの基本思想は理解したつもりですが,データストアを分けたり,イベントソーシングと組み合わせたりする必要性がうまく説明できなかったので,ここに整理しようと思います。

※執筆途中です

やまやま

コマンド・クエリ責務分離とは?

コマンド・クエリ責務分離(CQRS)とは,ドメイン駆動設計(DDD)のためのアーキテクチャパターンの1つです。
CQRSではアプリケーションコードをコマンド(更新系)とクエリ(参照系)に分離します。このような分離を行うことで,更新系・参照系それぞれに対する別々の最適化が可能になるのです。

では,その最適化とは具体的にどのようなものなのでしょうか?そこからどうイベントソーシングまで繋がるのでしょうか?

やまやま

CQRSが生まれた過程

ソフトウェア開発において,バックエンドでデータ(モデル)を更新する作業と,主にUI側の要望からデータを書き換えず取得(参照)だけする作業は,本質的に異なります。
更新系では,基本的に処理をするべきエンティティのIDが既知であり,特定の対象に対して処理をしていきます。一方で参照系では,おもにUI側の要望で,様々な条件のもとで検索(クエリ)を行うのです。
CQRSが生まれたのは,これら更新系・参照系のすべてを,リポジトリという更新系特化のデータ永続化機構だけを使って実現しようとした結果,壁にぶつかったからです。その過程を見ていきましょう。

前提:リポジトリとは

前提の確認ですが,Repositoryパターンにおけるリポジトリは,単にデータアクセスのメカニズムを隠蔽するだけのものではなく[1],インメモリなドメインオブジェクトのコレクションのように振る舞うことを目指すものです。つまり,DBの種類や設計に関係なく,UserRepository.findById(userId)をしたら過不足ない情報をもったUserオブジェクトを返してくれて,UserRepository.save(user)するときには,直接Userオブジェクトを渡せば良い,そんな存在です。

しかし,ここにおいてリポジトリが永続化の単位とするのは,ドメインオブジェクトなら何でも良いわけではありません。「集約ルート」たるエンティティでなければなりません[2]。詳細はこちらには書きませんが,要するに集約ルートとは,子関係にあるエンティティや値オブジェクトを全部従えた代表のエンティティです。

このあたりは以下の記事が参考になるでしょう。アンチパターンとその対策から,リポジトリパターンへの理解を深めることができると思います。
https://qiita.com/mikesorae/items/ff8192fb9cf106262dbf
https://qiita.com/os1ma/items/28f5d03d3b92e6a1e1d8
https://speakerdeck.com/j5ik2o/ji-yue-falseshe-ji-toshi-zhuang?slide=8

リポジトリが肥大化する例

しかしながら,リポジトリを「集約ルート」単位で作ったとしても,問題は残っています。それは,複雑なデータ検索要件をどう取り扱うかという問題です。
結論としては,リポジトリで複雑なクエリ要件を取り扱うことには限界がある,ということなのですが…

ためしに,ユーザーを検索するという機能を,リポジトリを使って実現しようと思います。
リポジトリをシンプルに保つならば,以下のようにするのが一番でしょう。いったん全取得してから,フィルタリングしていきます。

interface IUserRepository {
    findById(id: UserId): Promise<User>;
    findAll(): Promise<User[]>;
    save(user: User): Promise<void>;
    delete(id: UserId): Promise<void>;
}

// 一旦全ユーザーを取得
const allUsers = await userRepository.findAll();
// アクティブで男性で20歳以上のユーザーを取得
const activeMaleUsersOver20Yo = allUsers
    .filter(user => user.isActive)
    .filter(user => user.sex === "male")
    .filter(user => user.age >= 20);

でも,このコードのお行儀が悪いことは明らかといっていいでしょう。Userの数が少なければ良いのですが,たとえば100万件のUserを一旦全取得してからフィルタリングする,という状況を想像してみれば…地獄です。

それなら,各検索条件ごとにメソッドを用意したらどうでしょうか?

interface IUserRepository {
    findById(id: UserId): Promise<User>;
    findAll(): Promise<User[]>; // 全ユーザーを取得
    save(user: User): Promise<void>;
    delete(id: UserId): Promise<void>;
    // 各検索条件に対応
    findActiveUsers(): Promise<User[]>;  // アクティブユーザーリストを取得
    findMaleUsers(): Promise<User[]>;   // 男性ユーザーリストを取得
    findFemaleUsers(): Promise<User[]>; // 女性ユーザーリストを取得
    findUsersOver20Yo(): Promise<User[]>;   // 20歳以上のユーザーリストを取得
    findUsersUnder20Yo(): Promise<User[]>;  // 20歳未満のユーザーリストを取得
    findMaleUsersOver20Yo(): Promise<User[]>;  // 男性・20歳以上のユーザーリストを取得
    findFemaleUsersUnder20Yo(): Promise<User[]>;  // 女性・20歳以上のユーザーリストを取得
    findActiveMaleUsersOver20Yo(): Promise<User[]>;  // アクティブ・男性・20歳以上のユーザーリストを取得
    // ...
}

const activeMaleUsersOver20Yo = await userRepository.findMaleUsersOver20Yo();

もっと悪くなりましたね。リポジトリのクエリメソッドが複雑になりました。

データ検索効率が悪くなる例(N+1クエリ問題)

他にも,欲しいデータが複数集約間をまたがるとき,N+1クエリが発生しやすくなります。(サンプルコードはこちらの記事を参考にTypeScript風に書き換えました)

const reservations: ReservationDto[] = reservationRepository.findByIds(ids)
    .map(reservation => {
        const hotel = hotelRepository.findById(reservartion.hotelId);
        const customer = customerRepository.findById(reservation.customerId);
        return new ReservationDto(reservation, hotel, customer);
    });

CQRSへ

このように,実装の肥大化や検索効率の問題から,リポジトリで複雑なクエリ要件(≒検索条件)に対応することには限界があるのです。

そこで新しく用いるのが「クエリ」です。かんたんに言えば,従来の更新系=「コマンド」とは別に,検索特化のコードを用意しようという取り組みです。
これまで更新系のデータ永続化機構を使って,参照系の検索条件まで手を伸ばそうとしていたところを,参照には参照用のモノを用意しよう,ということです。

検索特化のコードを書いた所で,何も工夫しなければ,その部分の肥大化は防げないかもしれません。それでも,性質の異なる「更新系」のコードの邪魔をせず,「参照系」だけを閉じ込め,さらに各検索条件に合わせて最適なクエリを書いてよい,ということになれば十分ありがたいことでしょう。

たとえば,クエリ(参照)側からはGraphQLを利用するのも手でしょう。バックエンドにいくつもAPIエンドポイントをたてることなく,クライアント側から好きなようにクエリからです。

脚注
  1. 単にデータアクセスのメカニズムを隠蔽することを目指すのは,DAO (Data Access Object) パターンです。参考記事: https://blog.fukuchiharuki.me/entry/use-repository-and-dao-according-to-the-purpose ↩︎

  2. 集約・集約ルートについての参考記事はこちら: https://masuda220.hatenablog.com/entry/2021/05/07/142824 ↩︎

やまやま

ここからどんな記事を書く予定なのか?についても軽くメモしておきます。


ここまででCQRSの嬉しさを解説しました。しかし話はそう単純にはいかず,データソースの分離とイベントソーシングもついて回ります。その理由を以下に簡単に解説します。

コマンド側とクエリ側で別々のデータソースを使う必要がある理由

以下は書きたいことの要約です。後日暇なときに加筆しようと思っています。

たとえば「価格計算ロジック」は,コマンド側のビジネスロジックで実現されますが,通常コマンド側のデータソースに保存することはありません。また,「日付」をコマンド側のデータソースに保存する時,「2023年12月12日」のようにわかりやすい値として保存するとは限らず,「19890239」のような数値に変換して保存するかもしれません(こちらは,コマンド側のドメインモデルやファクトリ等でパースする)。
では,クエリ側からこれらの情報が欲しくなった時,どうしましょうか?クエリ側からコマンド側のドメインモデルに依存するのはまずいでしょう。一方クエリ時に価格計算をしたり日付パースをしたりするのはビジネスロジックの流出です。
そのため,クエリ側で必要になる情報を,クエリ側にわかりやすい形で別途保存しておきたい,これがデータソースまで分離するねらいです

別々のデータソースをどう同期するか?

前章で解説したとおり,理想的にはデータソースの分離が必要になります。では,そのように分離したデータソースの情報をどう同期しましょうか?そこで出てくるのがイベントソーシングです。

やまやま

ここまで書いておいてなんですが,私はCQRSを使いたい一方で,イベントソーシングは使いたくありません。イベントソーシングのシステムアーキテクチャは少々複雑すぎる気がするので…特にスマホアプリ開発ではデータベースをFirebaseのNoSQLで済ませてしまうことも多く,DBを他に増やして難解な構成にするのは必ずしも良いこととは言い切れません。
ですので,この記事もちゃんと執筆が続けられれば,言うなれば「軽量CQRS」:どこかで妥協して,気軽に導入できるCQRSを実現する方法を考察しようと思います。更新系の従来のDDDをベースに,必要な時のみQueryServiceのようなサービスクラスでクエリ要件に個別に対応する,というのが現実的でしょうか…

Event SourcingなしのCQRS:
https://little-hands.hatenablog.com/entry/2019/12/02/cqrs

CQRS/Event Sourcingまわりの参考記事:
https://blog.j5ik2o.me/entry/2020/09/18/172612
https://logmi.jp/tech/articles/324798
https://note.com/j5ik2o/n/n20aadb440a9b
https://postd.cc/using-cqrs-with-event-sourcing/

CQRSの実用や「軽量CQRS」に関する記事: