リードモデルの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問題](https://qiita.com/TsubasaTakagi/items/8c3f4317ad917924b860)
[N+1問題は1+N問題](https://qiita.com/hisonl/items/763b9d6d4e90b1606635)

1度の問い合わせ(1)でN回の問い合わせ(2)(3)が発生します。さらに、この実装では(2),(3)で発生する問い合わせはID値の重複を排除しないまま、問い合わせているのでつらいことになってます…。(最低でもID集合をSetに変形してからやってほしい…)
（4）のリードモデルの構築も、DAOのレイヤーで効率的にやっていたものが、アプリケーション空間に露出していて適切な設計に思えません。（といいながら、過去にこういう設計をやったことがある。心より恥じる…)

```scala
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)
```

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

```scala
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は書き込みのメソッドを持たないので、ドメイン側に予期せぬ副作用を与えることがありません。

```sql
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
;
```

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

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

#### 懸念点

- 読み込み専用DAOにビジネスロジックは入り込まないのか？
    - 副作用を起こすときにビジネスルールを満たしているか確認するようにすれば、読み込み側では関与する部分がほとんどないはずです。
- ドメインモデルの変更をした際に、リードモデルの変更が漏れるのでは？
    - 副作用を起こすドメイン側と、副作用を扱わないリード側は、アプリケーション上では疎結合になりますが、ドメインモデルは結局永続化と紐付くことが多いので、スキーマに変更が適切に伝搬されていれば、リードモデルの変更漏れも防げるはずです。
- RDBMSじゃない場合でもできるの？
    - 少し複雑になりますが、読み込み用のストレージがRDBMSでない場合も、書き込み操作のイベントをフックし、リードモデルを構築するプロセスをはさめば、リードモデルを構築することも可能です。


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