LoginSignup
103
64

More than 3 years have passed since last update.

「集約」でデータアクセスの 3 つの課題に立ち向かう ~ 大量の Repository・整合性のないオブジェクトのロード・N + 1 問題 ~

Last updated at Posted at 2019-12-11

はじめに

書籍『実践ドメイン駆動設計』や『.NET のエンタープライズアプリケーションアーキテクチャパターン』などにおいて、「集約」という概念が非常に重要とは言われていますが、その理由はあまり詳しく解説されていません。
(少なくとも、私は書籍を読んだだけでは分かりませんでした)

しかし、ある時データアクセスの実装について考えていたところ、「集約」が様々な課題を解決してくれる重要な概念だということに気付きました。

データアクセスの課題

アプリケーションを実装していると、データアクセスに関するコードで以下のような課題に突き当たることが多々あるのではないでしょうか。

  • サービスクラスに大量の Repository をインジェクションすることになる
  • オブジェクトの一部だけをロードして、子要素が NULL の状態が生じる
  • データアクセスの設定や記述方法によって N + 1 問題が発生する

これは、ある程度以上の規模のアプリケーションを実装すれば必ず遭遇する問題です。

実は、「集約」こそがこれらに立ち向かう方法なのです。

よくあるコード

Service クラスに大量の Repository をインジェクションすることになったコードを見たことがないでしょうか ?

※ 以下で示すサンプルコードは Java + Spring Boot を意識しています。

@Service
public class OrderService {
    private UserRepository userRepository;
    private UserAddressRepository userAddressRepository;
    private OrderRepository orderRepository;
    private OrderDetailRepository orderDetailRepository;
    private ProductRepository productRepository;
    private ProductPriceRepository ProductPriceRepository;
    :
    :
    :
}

このコードには以下のような問題があります。

  • Service クラスに大量に Repository を書くことになる
  • User、Order、Product クラスが適切に構築されているかがサービスクラスに散在してしまう
  • N + 1 問題が発生するかどうかもサービスクラスの実装者に依存してしまう

これらはどのように解決すればいいのでしょうか ?

「集約」を取り入れる

さて、「集約」という考え方を取り入れてみます。

集約は、永続化の単位となる、クラスの塊のことです。

例えば、このアプリケーションにおいて、Order と OrderDetail は同時に生まれ、同時に保存されるとします。
その Order や OrderDetail をまとめたのが「集約」です。
「集約」の一番親を成すクラスである Order のようなものを「集約ルート」と呼びます。

ここが非常に重要なポイントなのですが、Repository は集約の単位で作成します
OrderRepository を作成し、OrderDetailRepository は作成しません。
OrderDetail の永続化に関する処理は OrderRepository の内部に隠蔽するのです。

そもそも、Repository パターンは、本来この単位で作成することが前提となるデザインパターンです。
OrderDetailRepository を作ること自体が Repository パターンでは誤りなのです。

Repository パターンの本来の使い方については、「やはりお前たちのRepositoryは間違っている」という記事が非常に分かりやすいです。

さて、「集約」という考え方を取り入れることで、コードにはどのような変化が生じるのでしょうか ?

書き込み処理

OrderRepository と OrderDetailRepository が別々にあると、Service クラスには以下のようなコードが登場するはずです。

orderRepository.save(order);
order.orderDetails.forEach(orderDetailRepository::save);

集約を使用すると、上記のコードが

orderRepository.save(order);

というコードになります。

これにより、Order と OrderDetail が同時に永続化されることが、Repository を呼び出す側の Service クラスに依存しなくなりました。

つまり、Order と OrderDetail の間で成立すべき関係性が、呼び出し側に依存しなくなったのです。

また、コードの複数箇所で Order と OrderDetail を保存するようなことがあった際にも、その永続化の処理が Repository の内部実装にて共通化されました

読み込み処理

OrderRepository と OrderDetailRepository が別々に存在する場合、Order の取り出しは以下のようなコードになります。

Order order = orderRepository.findById(orderId);
OrderDetails orderDetails = orderDetailRepository.findByOrderId(orderId);
order.setOrderDetails(orderDetails);

この 3 行の処理は、Order クラスの整合性のない瞬間を許容しています。
1 行目の Order order = orderRepository.findById(orderId); を実行した瞬間、order インスタンスの orderDetails フィールドはおそらく NULL です。
NULL はみなさんご存知の通り、様々なバグの可能性を生じます。
また、NULL でないとしても、空の List などといった、事実とは異なる状態になっていることでしょう。

このように、実際のデータとは異なるオブジェクトをロードしてしまわないよう、Order 集約の一部である OrderDetail は Order と同時にロードすべきなのです。
本来のリポジトリパターンでは以下のようなコードになります。

Order order = orderRepository.findById(orderId);

OrderRepository と OrderDetailRepository を統合することで、サービスクラスにおいて整合性のない状態を許容しない実装ができるようになるのです。

同時に

  • サービスクラスに大量のリポジトリがインジェクションされる問題
  • N + 1 問題の発生がサービスクラスに依存する問題

も解決しました。

ファクトリでオブジェクトの生成にも整合性を保証する

Order と OrderDetail の組み立てをサービスクラスに書かないというのは、「ファクトリ」についても同じ考え方です。
「集約」を整合性を持って存在させるため、その組み立ては専用の場所に共通化するのです。

Lazy Fetch による N + 1 問題

O / R マッパによっては、Order と OrderDetail などを、一方のクラスがもう一方のクラスの参照を持つようにマッピングしてくれるものがあります。

その場合、Order -> OrderDetail -> Product -> ProductDetail のようにデータベースのリレーションにあわせてドメインモデルでも参照を持たせていくと、非常に大きなオブジェクトが出来上がります。
これほど大きなオブジェクトを無駄にロードしないようにするには LazyFetch をなどを取り入れる必要が出てきますが、すると今度は N + 1 問題が発生しやすくなってしまう場合があります。

これも「集約」を取り入れることで制御しやすくなります。

Order、OrderDetail、Product、ProductDetail の例で言うと、Order と OrderDetail で一つの集約とし、これは 1 回のデータアクセスで取得します。
Product、ProductDetail については別の集約とし、OrderDetail からは ProductId だけを参照するようにし、Product 自体を参照しないようにします。

どこまでを 1 回のデータアクセスで取得するかを明確にすることで、大きすぎるオブジェクトのロードという問題や N + 1 問題に対抗しつつ、さらには整合性のないオブジェクトのロードも防ぐのです

集約をまたがった JOIN はどうするのか

さて、Order と Product が別の集約である場合、それらを同時に取得して画面に表示するような場合はどうするのか、といった疑問が生じることになります。

その回答は 2 つあります。

1 つは、アプリケーションで JOIN することです。
もう 1 つは、CQRS です。

DDD と CQRS が相性が良いと言われるのはなぜか

集約をまたがった JOIN について考えていくと、CQRS に到達します。

集約をまたがった処理については Repository で無理に実施するのではなく、別途 QueryService (+ QueryModel) のようなクラスを作成し、特別なクエリとしてデータベースから取得するのです。

CQRS が必要になる理由については、「CQRS実践入門 [ドメイン駆動設計]」という記事が非常に分かりやすいです。

ただし、参照系を全て QueryService に任せるようにすると、今度はデータモデルの変更時の影響範囲が広くなりやすいといった課題もあるため、QueryService を使いすぎることも一概に良いとは言えません。
そのため、私はアプリケーション上で JOIN 処理を書くことも許容し、どうしても特化したクエリを実行したい場合だけ QueryService を作成しています。

これらを踏まえたアプリケーション構成については、以下の記事にまとめています。
https://qiita.com/os1ma/items/286eeec028e30e27587d

おわりに

ドメインモデルでは Entity、Value Object に注目されがちですが、個人的に「集約 (Aggregate)」が一番重要といっても過言ではないと考えています。
また、試した訳ではありませんが、「集約」の考え方はトランザクションスクリプトであっても取り入れられるかもしれません。

データアクセスのよくある課題に立ち向かうため、是非「集約」を取り入れてみてください。

参考

書籍

Web

103
64
0

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
103
64