実務Webアプリ開発編です。今回のテーマは、CQRS(コマンド・クエリ責務分離)とは何か、なぜコマンドとクエリを分けるのかです。

前回は、状態を変えるユースケース、つまりコマンド側を七つの手順で読みました。今回は、その反対側にある読み取りの経路を扱います。

CQRSは、状態を変える処理(コマンド)と、データを読んで返す処理(クエリ)を分ける考え方です。この二つは、何が違うから分けるのでしょうか。

以下のような方に役立つ内容となっています。

この記事が役に立つ方

  • CQRSという言葉は聞くが、何と何を分けるのかピンとこない
  • 一覧取得にもドメインモデルやリポジトリが必要なのか迷っている
  • DDDの集約とCQRSの関係を整理したい

以下のようなMentorAppを題材として進めます。

題材とするMentorApp

GitHubにドキュメント・コードの一式があります。

prota-p/MentorApp: Blazor Server × クリーンアーキテクチャ × DDD の学習用リファレンス実装(実務Webアプリ開発編の題材)

集約の考え方と、前回のコマンド側のユースケースが土台になります。以下の記事とつなげて読むと理解しやすいです。

【C#/Blazor】実務Webアプリ開発編 (20)DDDのエンティティ・集約・集約ルートを実コードで理解する ~不変条件の守り方~ 実務Webアプリ開発編です。前回は、Emailを題材に値オブジェクトの実装を見てきました。 今回はPart IV(ドメイン層の実...
【C#/Blazor】実務Webアプリ開発編 (30)アプリケーションサービスの責務 ~ユースケースを進行させ、判断はドメインに委ねる~ 実務Webアプリ開発編です。今回のテーマは、「Application層の役割・責務は何か?」です。 シリーズとしては、前回でユニ...
プロ太

前回の最後に予告した「なぜ読み書きを分けるのか」を、集約の境界から考える回です。

集約は変更を守る境界、表示は境界をまたぐ

更新と表示では、同じデータを扱っていても求めるものが違います。ネットショップの注文を例に考えてみましょう。

注文には「キャンセルしたい」と「一覧を見たい」という二つの要求があります。まずはキャンセルからみていきます。

図1: 集約の中で完結する更新、集約をまたぐ表示
図1:集約の中で完結する更新、集約をまたぐ表示

キャンセルのように、状態を変える要求をコマンドと呼びます。触れるのは注文集約だけで、顧客集約には入りません。

注文集約には「発送前ならキャンセルできる」というルールがあります。キャンセルという変更は、必ずこのルールを通ります。

第20回でみたとおり、集約は不変条件(常に守られるべきルール)を守る単位です。状態を変えられるのは、集約ルートのメソッドを通したときだけでした。

一方、一覧のようにデータを読んで返す要求はクエリです。一覧は顧客名も並べるので、注文と顧客の二つの集約から読みます。

プロ美

一覧の1行は、二つの集約をまたいだ形だよね。集約ごとに別々に読まなくていいの?

プロ太

読むだけなら状態は変わらないので、守るべき不変条件が関係しません。集約ごとに分けて読む必要はないんです。

集約の境界は、変更を安全にするためのものでした。読むだけの処理が、この境界に従う理由はありません。

集約ごとにリポジトリで読んで組み合わせることもできます。ただそれは、変更のための道具で表示を組み立てる遠回りです。

だから一覧は、境界をまたいで必要な項目を集め、画面に表示したい形で返してかまいません。

プロ太

集約は変更を守るための境界。読むだけなら守るものがないので、境界をまたいで自由に形を作ってよい——これが今回の核心です。

CQRSとは何か

このように、状態を変える処理と、データを読んで返す処理を分けて設計する考え方をCQRSと呼びます。

CQRSはCommand Query Responsibility Segregationの略で、日本語では「コマンド・クエリ責務分離」と訳されます。

改めて整理すると、コマンドは状態を変える要求、クエリはデータを返す要求です。

コマンドクエリ
目的状態を変えるデータを返す
例キャンセルする・完了する一覧を見る・詳細を見る・CSVを出力する
扱うモデル集約(ルールを持つ)表示用モデル(DTO)
集約の境界境界に従い、ルールを通す境界をまたいで読んでよい

クエリの相手は画面に限りません。帳票やCSVの出力、他のシステムに返すAPIも、読んだものを返すだけならクエリです。

扱うモデルも分かれます。クエリが使うDTOは、画面に渡すデータを運ぶだけの入れ物で、ルールを持ちません。

そして、コマンドは集約の境界に従ってルールを通し、クエリは境界をまたいで読んでかまいません。前の章でみた違いです。

CQRS自体は、DDD専用の考え方ではありません。ただ、集約を使って設計していると、分ける理由がはっきりします。

構成としてはどうなるでしょうか。画面からの要求は、コマンドとクエリで別々の処理へ向かいます。

図2: 同じDBへ向かう二つの経路
図2:同じDBへ向かう二つの経路

コマンドは更新処理へ進みます。集約を読み込み、ルールを通してから保存します。

見落としやすいのですが、コマンド側もDBを読みます。変更する前に、対象の集約を取り出す必要があるからです。

つまり区別の基準は、読むか書くかではありません。ルールを通すための処理ならコマンド、読んだものを返すための処理ならクエリです。

クエリは取得処理へ進みます。集約をまたいで読み、表示用モデルに詰めて画面へ返します。

そして、コマンドもクエリも使うのは同じDBです。CQRSと聞くと、読み取り専用のDBを別に用意する構成を思い浮かべるかもしれません。

それは分けたあとに選べる、追加の設計判断です。CQRSの出発点は、同じDBのまま処理とモデルを分けることにあります。

発展した構成

読み取り用のDBを分ける、イベントソーシングと組み合わせるといった発展形もあります。

MentorAppはどちらも採用していません。全体像はMicrosoft LearnのCQRSパターンが参考になります。

MentorAppの実装をみる

ここからはMentorAppの実装で、二つの経路を確かめます。題材は、メンタリングの「完了」と「一覧取得」です。

この画面に、二つの経路が並んでいます。一覧の表示がクエリ、各行の「完了」ボタンがコマンドです。

MentorAppのメンタリング一覧画面

関係するファイルは次の場所にあります。コマンド側とクエリ側で、置き場所が分かれています。

src/
├── MentorApp.Application/
│   ├── Contracts/
│   │   └── Queries/
│   │       └── IMentorshipQueryService.cs  ← クエリ(今回)
│   └── Mentorships/
│       └── MentorshipService.cs  ← コマンド
├── MentorApp.Domain/
│   └── Models/
│       └── Mentorships/
│           ├── IMentorshipRepository.cs  ← コマンド
│           └── Mentorship.cs  ← コマンド
└── MentorApp.Infrastructure/
    └── Persistence/
        ├── Queries/
        │   └── MentorshipQueryService.cs  ← クエリ(今回)
        └── Repositories/
            └── MentorshipRepository.cs  ← コマンド

コマンド側は、これまでの回でみてきた集約・リポジトリ・アプリケーションサービスです。

クエリ側は、契約がApplication層のContracts/Queries、実装がInfrastructure層のPersistence/Queriesにあります。

二つの経路の図(図2)の各箱が、MentorAppではどのクラスにあたるかをまとめると次のとおりです。

図の箱MentorAppでは
画面Razorコンポーネント
更新処理MentorshipService(Mentorship集約・リポジトリ・作業単位)
取得処理IMentorshipQueryService / MentorshipQueryService
DBAppDbContextとDB

更新処理は、前回みたアプリケーションサービスです。集約・リポジトリ・作業単位を使います。

取得処理は、クエリ専用のQueryServiceです。どちらも、最後は同じAppDbContextからDBへ向かいます。

コマンド:一つの集約の中でルールを通す

コマンド側の例は、一覧の「完了」ボタンから呼ばれるMentorshipServiceの、メンタリングを完了するメソッドです。

    public async Task<Mentorship> CompleteMentorshipAsync(
        Guid mentorshipId,
        CurrentUser currentUser,
        CancellationToken cancellationToken = default)
    {
        // …(例外処理の殻・2 行省略)
            await using var uow = await unitOfWorkFactory.CreateAsync(cancellationToken);

            var mentorship = await uow.Mentorships.FindByIdAsync(mentorshipId, cancellationToken)
                ?? throw new KeyNotFoundException($"メンタリング関係が見つかりません: {mentorshipId}");

            var isMentor = currentUser.Role == Role.Mentor && mentorship.MentorUserId == currentUser.UserId;
            if (currentUser.Role != Role.Admin && !isMentor)
                throw new UnauthorizedAccessException("このメンタリングを完了する権限がありません。");

            var now = timeProvider.GetUtcNow();
            mentorship.Complete(now);
            await uow.SaveChangesAsync(cancellationToken);

            // …(ログ・3 行省略)

            return mentorship;
        // …(例外処理・6 行省略)
    }

まずリポジトリで、完了させるMentorship集約を1つ取り出します。コマンド側も、ここでDBを読んでいます。

権限を確かめたら、状態の変更は集約自身のCompleteに任せ、作業単位でまとめて保存します。このメソッドが触れている集約は、Mentorshipの1つだけです。

Completeの中には「Active状態のときだけ完了できる」というルールがあります。変更は必ずこのルールを通ります。

    public void Complete(DateTimeOffset endedAt)
    {
        if (Status != MentorshipStatus.Active)
            throw new InvalidOperationException("Active 状態の Mentorship のみ完了にできます。");

        Status = MentorshipStatus.Completed;
        EndedAt = endedAt;
    }

取り出しに使ったIMentorshipRepositoryもみておきます。ここには、MentorAppの方針がコメントで書かれています。

/// <summary>
/// メンタリング集約のリポジトリインターフェイス(Command側)
/// </summary>
/// <remarks>
/// <para>
/// Domain 層で定義し、Infrastructure 層が実装する(依存性逆転の原則)。
/// </para>
/// <para>
/// CQRSパターンにおけるCommand側の責務を担当。
/// 状態変更の前処理(取得→更新)に使用し、一覧取得などのQuery操作は
/// Application層のIMentorshipQueryServiceが担当する。
/// </para>
/// <para>
/// 別集約(User)のIncludeは行わない。表示用途での結合は
/// QueryService側で行う。
/// </para>
/// </remarks>
public interface IMentorshipRepository
{
    public Task<Mentorship?> FindByIdAsync(Guid id, CancellationToken cancellationToken = default);

    public Task<bool> HasActiveMentorshipAsync(
        Guid mentorUserId,
        Guid menteeUserId,
        CancellationToken cancellationToken = default);

    public Task<bool> HasAnyActiveMentorshipByUserIdAsync(
        Guid userId,
        CancellationToken cancellationToken = default);

    public Task<Mentorship?> FindByMentorAndMenteeAsync(
        Guid mentorUserId,
        Guid menteeUserId,
        CancellationToken cancellationToken = default);

    public Task AddAsync(Mentorship mentorship, CancellationToken cancellationToken = default);

    public void Delete(Mentorship mentorship);
}

「別集約(User)のIncludeは行わない。表示用途での結合はQueryService側で行う」。コマンド側では、境界の外を読み込みません。

ここには読み込みのメソッドも並んでいますが、どれも更新の前の準備に使うものです。画面に返すためのものはありません。

たとえばHasActiveMentorshipAsyncは、進行中のメンタリングがあるかを調べるだけの読み込みです。重複作成を防ぐルールの判断に使うので、コマンド側にあります。

クエリ:集約をまたいで画面の形で返す

クエリ側の例は、この一覧です。表示している行を返すのが、MentorshipQueryServiceのGetAccessibleAsyncです。

リポジトリも作業単位も出てきません。ルールを通す必要がないので、DbContextから直接読みます。

    public async Task<IReadOnlyList<MentorshipDto>> GetAccessibleAsync(
        CurrentUser currentUser,
        Guid? filterByUserId = null,
        CancellationToken cancellationToken = default)
    {
        await using var dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);

        var query = dbContext.Mentorships.AsNoTracking();

        // …(読む範囲の絞り込み・11 行省略)

        return await query
            .OrderByDescending(m => m.StartedAt)
            .Select(m => new MentorshipDto(
                m.Id,
                // …(ID・状態・日時・4 行省略)
                m.MentorUser!.DisplayName,
                m.MenteeUserId,
                m.MenteeUser!.DisplayName))
            .ToListAsync(cancellationToken);
    }

Selectで、読んだ結果を一覧の1行(MentorshipDto)の形に組み立てています。

メンターとメンティーの表示名は、Userから取っています。Mentorship集約の外にあるデータです。

MentorUserやMenteeUserは、関連先のUserをたどるナビゲーションプロパティです(第26回)。取得メソッドを呼んでいるわけではありません。

EF CoreはSelectの式全体をSQLに変換し、UsersテーブルとのJOINとして、一覧を1回の問い合わせで取得します。

返すMentorshipDtoは画面の一覧に合わせた形で、ルールを持つメソッドはありません。

public record MentorshipDto(
    Guid Id,
    MentorshipStatus Status,
    DateTimeOffset StartedAt,
    DateTimeOffset? EndedAt,
    Guid MentorUserId,
    string MentorDisplayName,
    Guid MenteeUserId,
    string MenteeDisplayName);

集約の境界をまたいで読み、画面に表示したい形で返す。前半の注文一覧と同じことが、ここで起きています。

プロ美

同じMentorshipでも、コマンドではルールを持つ集約として、クエリでは一覧に並べるデータ(DTO)として扱っているんだね。

どんなときに分けるとよいか

コマンドとクエリを分けることには、利点と負担の両方があります。

MentorAppでは一覧も詳細もQueryServiceが引き受け、リポジトリに画面向けのメソッドはありません。画面を足しても、集約とリポジトリには手を入れずに済みます。

観点内容
利点画面を足しても、集約とリポジトリに手を入れずに済む
向く場面変更のルールと画面の要件が、別々に増えていくアプリ

分ける価値が出るのは、状態変更のルールと画面の取得要件が、それぞれ別々に増えていくアプリです。

一方で、管理するものは増えます。表示用モデルのクラス、取得処理、そのテストが、コマンド側とは別に必要になります。

観点内容
負担表示用モデル・取得処理・テストが、コマンド側とは別に要る
向かない場面画面がテーブルそのままの単純なCRUD

画面がほぼテーブルそのままの単純なCRUDなら、この負担のほうが目立ちます。更新と表示に同じモデルを使うのも十分な選択肢です。

まとめ

今回は、CQRSとは何か、なぜコマンドとクエリを分けるのかを、集約の境界から整理しました。重要なポイントは以下です。

今回のポイント

  • 集約は、変更のルール(不変条件)を守るための境界
  • 読むだけなら守るものがないので、クエリは集約の境界をまたいで読んでよい
  • CQRSは、状態を変えるコマンドと、データを返すクエリを分ける設計
  • DBを分ける必要はない。同じDBのまま、処理とモデルを分けるのが出発点
  • 区別の基準は読むか書くかではなく、ルールのためか、読んだものを返すためか

次回は、今回入口だけみたクエリ側を主役にします。MentorAppのQueryServiceを読み、認可での絞り込みやDTOへの詰め方をみていきます。

プロ太

同じデータでも、変えるときと読むときでは守るものが違う。それがCQRSの出発点でしたね。

引き続き、実務的なWebアプリの開発について一緒に学んでいきましょう!

ABOUT ME
プロ太
ソフトウェア開発を楽しく、効率的に行う方法を追求しています。 開発者の視点から技術的課題に向き合い、「純粋な技術的興味に基づく探求」と「実践的な課題解決」という二つの柱を両輪として活動しています。