CQRSが解決する課題

CQRSが解決する課題

本記事はアジアクエスト Advent Calendar 2024の記事です。

目次

    はじめに

    最近「CQRS (Command Query Responsibility Segregation)」という言葉を耳にする機会が増えてきました。
    設計やアーキテクチャにも流行りがありますが、なんとなく採用することがないよう解決する課題が何なのか調べました。

    ターゲット読者

    • CQRSという言葉は聞いたことがあるが、よく知らない人
    • DDDがどういった設計か知っている人

    CQRSとは何か(超ざっくり)

    • 「コマンド(書き込み)」と「クエリ(読み取り)」という2つの責務に分離するアーキテクチャパターン。
      • コマンド:システムの状態を変える操作(データの作成、更新、削除)
      • クエリ:システムの状態を読む操作(データの取得、集計、検索)

    この2つを別々のモデルやストアで扱うことで、独立した拡張性・スケーラビリティを確保できると言われています。
    202412_cqrs_01

    これはどんな課題を解決しているのでしょうか。
    パフォーマンスやスケーリングの課題に対する解決策として捉えられるケースも多いですが、今回はドメインとUIからの異なる拡張要求という観点から見てみます。

    従来アーキテクチャが抱える問題

    これまで、DDDではよく「1つのドメインモデルでコマンド(書き込み)とクエリ(読み取り)を処理する」アーキテクチャが取られてきました。

    これ自体は標準的なやり方ですが、システムが成長し、要求が多様化する中で以下の課題が出てきます。202412_cqrs_02

    異なる拡張要求

    追加機能の要望などでは以下のような要求があります。

    • UIやレポート要件による「読み取り強化」の要求
    • ビジネスルールやドメイン知識の追加による「書き込みロジック強化」の要求

    読み取り強化の要求というのはバックエンド・DBの構造は変えずに表示させるものだけ変更したいと言ったケースです。
    例えば何らかのデータを一覧化したり、複雑なフィルター・合成処理を噛ませて表示するダッシュボードなど。

    こうしたUIからの変更要求はドメインモデルに直接影響がないので、ビジネスルールやドメイン知識の追加といった変更とは要求の性質が異なります。
    異なる性質の要求に対し、変更対象となるモデルは同じなのでモデルが複雑化します。

    例えば下記の例を見てみます。
    下記はクエリもコマンドも同じクラスに収められたエンティティとリポジトリです。

    // 単純なOrderエンティティ
    public class Order
    {
    public Guid Id { get; private set; }
    public string CustomerName { get; private set; }
    public DateTime OrderDate { get; private set; }
    public List<OrderItem> Items { get; private set; }

    public Order(Guid id, string customerName, DateTime orderDate)
    {
    Id = id;
    CustomerName = customerName;
    OrderDate = orderDate;
    Items = new List<OrderItem>();
    }

    // コマンド的操作: ドメインロジックに従ったアイテム追加
    public void AddItem(string productName, int quantity)
    {
    if (quantity <= 0)
    {
    throw new InvalidOperationException("Item quantity must be positive.");
    }
    Items.Add(new OrderItem(productName, quantity));
    }

    // クエリ的操作: 合計数量取得
    // ここでエンティティがクエリロジックも担当している
    public int GetTotalQuantity()
    {
    return Items.Sum(i => i.Quantity);
    }
    }
    // 単一のリポジトリが読み書き両方を担う
    public class OrderRepository
    {
    private readonly List<Order> _orders = new List<Order>();

    // コマンド操作: 新規作成や更新の保存
    public void Save(Order order)
    {
    var existing = _orders.FirstOrDefault(o => o.Id == order.Id);
    if (existing == null)
    {
    _orders.Add(order);
    }
    }

    // クエリ操作: 検索ロジックも同一リポジトリで担当
    public IEnumerable<Order> GetOrdersByCustomerName(string customerName)
    {
    return _orders.Where(o => o.CustomerName.Equals(customerName, StringComparison.OrdinalIgnoreCase));
    }

    public int GetTotalItemCountForAllOrders()
    {
    return _orders.Sum(o => o.GetTotalQuantity());
    }
    }

    ドメインロジックにクエリロジックが混じってしまっています。
    せっかくDDDにしてもドメインロジックに異物混入しては元も子もないです。

    更に、ここに複数のリポジトリを使わないとデータを取れないようなUIの要求が来るとN+1クエリの発生、非効率なDTO詰め替え作業が発生しやすくなります。

    // ユースケース: コマンド・クエリが混在し、3つのリポジトリから値を取得して1つのDTO集合を組み立てる例
    // ここでは、実際には画面表示に不要な内部情報もリポジトリから取得しなければならない状況を表現する。
    public class GetOrdersUseCase
    {
    private readonly ICustomerRepository _customerRepository;
    private readonly IOrderRepository _orderRepository;
    private readonly IProductRepository _productRepository;

    public GetOrdersUseCase(
    ICustomerRepository customerRepository,
    IOrderRepository orderRepository,
    IProductRepository productRepository)
    {
    _customerRepository = customerRepository;
    _orderRepository = orderRepository;
    _productRepository = productRepository;
    }

    public IEnumerable<OrderDTO> Execute()
    {
    // 本当は画面表示に必要なものだけ取得したいが、
    // 単一のモデルやリポジトリから全ての顧客、注文、製品を取得して組み立てている。
    // これにより不要なデータまで読み込むことになり、パフォーマンスが低下。

    var customers = _customerRepository.GetAllCustomers().ToList();
    var orders = _orderRepository.GetAllOrders().ToList();
    var products = _productRepository.GetAllProducts().ToList();

    // ここで、例えば本当は画面にはCustomerNameと一部のOrder情報だけがあればいいのに、
    // すべての顧客や全ての注文、全ての製品を取得してからフィルタリングしている。
    // 内部的なフィールド(InternalCode, IsInternalOnly, InternalValue)も引っ張ってしまう。

    // N+1クエリっぽい状況はリポジトリ内部で発生しうる(GetAll~が巨大なオブジェクト集合を返していると想定)
    // ここではLINQで無理やりデータをマージ・フィルタリングすることで、可読性は少し確保しているが、
    // 不要なデータ取得が行われている点は変わらない。

    var result = (from o in orders
    // 内部で必要な顧客情報をすべて取得済みで、そこからNameを後付けする
    join c in customers on o.CustomerId equals c.Id
    // 本当は特定の商品カテゴリだけが必要なのに、全てのProductを取得済み
    let product = products.FirstOrDefault() // 本当は商品IDで検索するなど複雑なロジックがあるとする
    // 不必要なデータを読み込んだ上で、画面用DTOには最低限の情報のみ詰め替え
    select new OrderDTO
    {
    OrderId = o.Id,
    CustomerName = c.Name,
    ProductName = product?.ProductName ?? "N/A"
    }).ToList();

    return result;
    }
    }

    かくして、ドメインモデルを上手くコードに落とせず、挙げ句パフォーマンスやスケーラビリティまで落とすことになっています。

    その原因となっていたのが、UIからの要求とドメインからの要求という異なる拡張要求に起因しているのでした。

    こうした課題をクエリとコマンドに分けることで解決してくれるのがCQRSです。

    まとめ

    CQRSという解決策から異なる拡張要求という観点から課題を認識することができました。
    しかし「ほとんどのシステムにとって、CQRSはリスクを伴う複雑さを追加する」ともあるので、現在の設計に合った適切な解決策を常に模索していこうと思います。

    CQRSはイベントソーシングと組み合わせたり、データストアをRead/Writeに分けるなど追加のアプローチを行えるようですが、難しいのでここでは触れません...。
    これからCQRSを検討する人も、単なる「流行りの手法」ではなく、こうした課題意識をもって取り組むと、より納得感のある選択ができるかもしれません。

    参考

    CQRS - Martin Fowler
    CQRS - Greg Young 
    CQRS パターン - Azure Architecture Center | Microsoft Learn 
    そろそろイベントソーシング・CQRSを使ってみてもいい頃なんじゃない? - Speaker Deck 
    CQRS実践入門 [ドメイン駆動設計] - little hands' lab 
    CQRS+ES(再)入門 - Speaker Deck 
    CQRSはEvent Sourcingなしで実現できるのか? - Speaker Deck

    アジアクエスト株式会社では一緒に働いていただける方を募集しています。
    興味のある方は以下のURLを御覧ください。