DDD
n+1
CQRS

リードモデルのN+1問題とCQRS


背景

集約とリポジトリなどをアプリケーションサービスやコントローラから呼び出し、書き込みや読み込みの要求を実装することがよくあります。ほとんどの場合、トランザクション整合性の観点から考えると、書き込み要求は集約単位になりますが、読み込みは結果整合性も含めると、複数種の集約を合成した、いわゆるリードモデルを返すことが多いです。この記事では、このリードモデルに起こるN+1問題とCQRSの関連性についてまとめたいと思います。


リードモデルを返す処理

みなさんは、どのようにしてリードモデルを構築していますか?

いろいろな方法がありますが、ここでは以下に観点を絞ってみたい。


  1. 複数種のリポジトリを使って集約を取得し、リードモデル用DTOに詰め直す

  2. リポジトリを使わず、ストレージに対応したDAOで、JOINするような問い合わせを行う


対象のドメイン

話をわかりやすくするために、想定のドメインが必要ですね。

ホテルの予約とします。登場するモデルは以下。


  • ホテル(Hotel)


    • ホテルID

    • ホテル名



  • 顧客(Customer)


    • 顧客ID

    • 顧客名



  • 予約(Reservation)


    • 予約ID

    • ホテルID

    • 顧客ID

    • 予約日時



そして、クライアントなどに返すリードモデルは予約一覧です。予約一覧の内容は、以下のように上記のモデルを合成したような結果が必要です。


  • 予約ID

  • ホテルID

  • ホテル名

  • 顧客ID

  • 顧客名

  • 予約日時


リポジトリを使う方法

いくらなんでもこんなコードは書いてないですよね…。N+1問題ではこのような事例がでてきます。以下に参考リンクを貼っておきますね。

N+1問題

N+1問題は1+N問題

1度の問い合わせ(1)でN回の問い合わせ(2)(3)が発生します。さらに、この実装では(2),(3)で発生する問い合わせはID値の重複を排除しないまま、問い合わせているのでつらいことになってます…。(最低でもID集合をSetに変形してからやってほしい…)

(4)のリードモデルの構築も、DAOのレイヤーで効率的にやっていたものが、アプリケーション空間に露出していて適切な設計に思えません。(といいながら、過去にこういう設計をやったことがある。心より恥じる…)

val reservations = reservationRepository.resolveAllWithOffsetLimit(...) // (1)

val hotels = reservations.map{ reservation => hotelRepository.resolveId(reservation.hotelId) } // (2)
val customers = reservations.map{ reservation => customerRepository.resolveById(reservation.cusotmerId) } // (3)
val readModel: Seq[ReservationReadModel] = reservations.zip(hotels).zip(customers).map{ case ((r, h), c) =>
ReservationReadModel(r.id, h.id, h.name, c.id, c.name, r.createdAt)
} // (4)

もう少し緩和したとしても、バッチ問い合わせするぐらいでしょうか。とはいえ、問い合わせの基となったモデル(今回の場合は予約)の構造によっては、関連して発生する問い合わせを減らせない可能性もあります。

val reservations = reservationRepository.resolveAllWithOffsetLimit(...)

val hotels = hotelRepository.resolveMulti(reservations.map(_.hotelId)) // 引数Setでもらいましょう
val customers = customerRepository.resolveMulti(reservations.map(_.customerId)) // 引数Setでもらいましょう
val readModel: Seq[ReservationReadModel] = ...

(1),(2),(3)の問い合わせをわざわざアプリケーション空間に戻して、合成処理を掛ける意味はほとんどありません。こんなコードをだらだら読まされるほど暇じゃないはずです(笑) まぁ、まだデータの規模が少ないうちはいいですけど、増えると…。


読み込み専用DAOを使う方法(CQRS)

上記のような複雑な問い合わせはドメインのコンポーネントを使って本当に実現すべきでしょうか?。CQRSでは、複雑なクエリ要件(複数種の集約を合成するようなクエリ要件)はドメイン層の責務とせずに、リード側のスタックで解決しようとします。これにならうと、上記のほとんどの問題が解決できます。

たとえば、MySQLなどのRDBMSであれば、以下のようなVIEWを定義し、読み込み専用DAOを実装すれば、SQLの問い合わせは一度で済みますし、アプリケーション空間で非効率なメモリ操作も軽減できます。読み込み専用のDAOは書き込みのメソッドを持たないので、ドメイン側に予期せぬ副作用を与えることがありません。

CREATE VIEW

reservation_r
(
id,
hotel_id,
hotel_name,
customer_id,
customer_name,
created_at,
)
AS
SELECT
r.id,
h.id,
h.name,
c.id,
c.name,
r.created_at
FROM
reservation AS r
LEFT JOIN hotel AS h ON r.hotel_id = h.id
LEFT JOIN customer AS c ON r.customer_id = c.id
;

val readModel: Seq[ReservationReadModel] = ReservationReadDao.findAllWithOffsetLimit(...)

考えてみたらすごく自明のことなのですが、書き込みと読み込みの責任を分ける発想がないと、なかなか気づけないのではないでしょうか。


懸念点


  • 読み込み専用DAOにビジネスロジックは入り込まないのか?


    • 副作用を起こすときにビジネスルールを満たしているか確認するようにすれば、読み込み側では関与する部分がほとんどないはずです。



  • ドメインモデルの変更をした際に、リードモデルの変更が漏れるのでは?


    • 副作用を起こすドメイン側と、副作用を扱わないリード側は、アプリケーション上では疎結合になりますが、ドメインモデルは結局永続化と紐付くことが多いので、スキーマに変更が適切に伝搬されていれば、リードモデルの変更漏れも防げるはずです。



  • RDBMSじゃない場合でもできるの?


    • 少し複雑になりますが、読み込み用のストレージがRDBMSでない場合も、書き込み操作のイベントをフックし、リードモデルを構築するプロセスをはさめば、リードモデルを構築することも可能です。



ということで、集約を跨ぐクエリ要件は読み込み専用DAOを使う方法がよいのではという提案でした。