実務Webアプリ開発編です。今回は、CQRSのクエリ側をEF Coreでどう書くかを、コマンド側と比べながら整理します。

前回は、状態を変えるコマンドと、データを返すクエリを分ける理由をみました。今回は、そのクエリ側の書き方を掘り下げます。

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

この記事が役に立つ方

  • CQRSの考え方は分かったが、クエリ側を実際にどう書くか迷っている
  • リポジトリを通さずに、何をどう読めばよいのか知りたい
  • 権限のないデータを、クエリでどう扱うか整理したい

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

題材とするMentorApp

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

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

前回のCQRSの考え方と、コマンド側のアプリケーションサービスが土台になります。以下の記事とつなげて読むと理解しやすいです。

【C#/Blazor】実務Webアプリ開発編 (31)CQRSとは?コマンドとクエリを分ける理由 ~集約は変更を守る境界、読み取りは境界をまたぐ~ 実務Webアプリ開発編です。今回のテーマは、CQRS(コマンド・クエリ責務分離)とは何か、なぜコマンドとクエリを分けるのかです。 ...
【C#/Blazor】実務Webアプリ開発編 (30)アプリケーションサービスの責務 ~ユースケースを進行させ、判断はドメインに委ねる~ 実務Webアプリ開発編です。今回のテーマは、「Application層の役割・責務は何か?」です。 シリーズとしては、前回でユニ...
プロ太

前回は入口だけみたクエリ側を、今回は主役にします。コマンド側と何が変わるのかに注目してください。

コマンド側と比べて、クエリ側で変わる四つのこと

前回みたとおり、画面からの要求は、コマンドとクエリで別々の処理へ向かい、同じDBにたどり着きます。

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

今回の主役は、下のクエリの経路です。上のコマンドの経路と比べると、書き方は何が変わるのでしょうか。

主に変わるのは、次の表の四つです。比べる相手は、第28回〜第30回でみたリポジトリ・作業単位・アプリケーションサービスです。

コマンド側クエリ側
返すもの集約(エンティティ)画面の形のDTO
読む範囲集約の内側だけ集約をまたいで1回で読む
変更追跡使う(作業単位でまとめて保存する)使わない(DbContextから直接読む)
権限の扱い変える1件に権限がなければ止める止めることも、見せてよい行に絞ることもある

コマンドは集約を返し、そのルールを通して変更します。クエリは変更しないので、画面に並べる形のDTOを返せば足ります。

コマンドのリポジトリは、集約の内側だけを読み込みました。クエリは守るルールがないので、集約をまたいで1回で読めます。

コマンドは、読み込んだ集約の変更を記録し(変更追跡)、作業単位でまとめて保存します。クエリは保存しないので、変更追跡は要りません。

最後が権限です。第30回のコマンドは、変える1件に権限がなければ、例外を投げて止めました。

クエリでも、画面そのものを使えない人なら止めます。ただ一覧なら、見せてよい行だけに絞って返す扱いもできます。

プロ美

止めずに絞ったほうがいいのは、どんなとき?

プロ太

一覧の中に、見せてよい行と見せられない行が混ざっているときです。見せられない行だけを除けば、一覧の画面はそのまま使えます。

四つが1本のクエリのどこに現れるかを、前回と同じネットショップの注文一覧でみてみます。店のスタッフが、担当店舗の注文を一覧で見る場面です。

図2: 四つのことがクエリ1本のどこに現れるか
図2:四つのことがクエリ1本のどこに現れるか

注文一覧は、1回のSQLで読みます。注文と顧客の二つの集約に分けて、別々に読むことはしません。

一覧に顧客名を並べるため、JOINで注文に顧客をつなぎます。これが「集約をまたいで1回で読む」です。

この例は、一覧を絞る場面です。担当店舗の注文だけをWHEREで絞るので、見せてはいけない行はDBから出てきません。

SELECTで読むのは、一覧に要る注文番号・金額・顧客名の列だけです。結果は、画面で表示する形にそろった状態で返ります。

受け取るのは画面用のDTOです。保存はしないので、変更追跡もしません。

EF Coreでクエリを書く基本

前の章では、クエリ側で変わる四つのこと(返すもの・読む範囲・変更追跡・権限の扱い)をみました。

そのうち、EF Coreの書き方に関わるところを、同じ注文一覧の例で確かめます。

担当店舗の注文一覧を、EF Coreで読むとこうなります。

var rows = await db.Orders
    .AsNoTracking()
    .Where(o => o.StoreId == staff.StoreId)
    .OrderByDescending(o => o.OrderedAt)
    .Select(o => new OrderListItemDto(
        o.Number,
        o.TotalAmount,
        o.Customer!.Name))
    .ToListAsync();

Selectで、結果をDTOの形に組み立てます。これを射影と呼び、SQLでもDTOに要る列だけが読まれます。

public record OrderListItemDto(
    string Number,
    decimal TotalAmount,
    string CustomerName);

顧客名は、別の集約へのナビゲーションからたどっています。EF CoreはこれをJOINに変えて、1回のSQLで読みます。

変更追跡を止める方法は二つあります。一つはAsNoTrackingで、読んだエンティティの変更をEF Coreが見張らなくなります。

もう一つは射影です。結果がDTOのようにエンティティでなければ、EF Coreはもともと変更追跡しません。

変更追跡の有無については、Microsoft LearnのTracking vs. No-Tracking Queriesに説明があります。

一覧を権限で絞るなら、その条件はWhereに書きます。ToListAsyncより前に書いた条件は、SQLのWHERE句になります。

プロ美

なるほど。これで、担当店舗の注文だけがDBから返ってくるんだね。

MentorAppの実装をみる

ここからはMentorAppの実装で、クエリ側で変わる四つのことを確かめます。題材の中心は、このトピック詳細画面です。

トピック詳細画面

クエリ側のファイルは、前回みた置き場所に、トピック・メンタリング・ユーザー・統計の4組があります。

src/
├── MentorApp.Application/
│   └── Contracts/
│       └── Queries/
│           ├── IDashboardStatsQueryService.cs  ← 契約とDTO
│           ├── IMentorshipQueryService.cs  ← 契約とDTO
│           ├── ITopicQueryService.cs  ← 契約とDTO
│           └── IUserQueryService.cs  ← 契約とDTO
└── MentorApp.Infrastructure/
    └── Persistence/
        └── Queries/
            ├── DashboardStatsQueryService.cs  ← 実装
            ├── MentorshipQueryService.cs  ← 実装
            ├── TopicQueryService.cs  ← 実装
            └── UserQueryService.cs  ← 実装

契約とDTOはApplication層のContracts/Queriesに、EF Coreを使う実装はInfrastructure層のPersistence/Queriesにあります。

今回は、このうちトピックのITopicQueryServiceとTopicQueryServiceを使って説明します。

この章では、次の順に確かめていきます。最初の四つが、クエリ側で変わる四つのことにあたります。最後はクエリ側のログです。

この章でみること

  1. 画面の形のDTO
  2. 集約をまたいで1回で読む
  3. 変更追跡しない
  4. 見せてよいものだけ返す
  5. 失敗を記録する

画面の形のDTO

ITopicQueryService.csにあるTopicDetailDtoが、トピック詳細画面に渡すDTOです。画面と並べてみます。

public record TopicDetailDto(
    Guid Id,
    Guid MentorshipId,
    string Title,
    TopicStatus Status,
    DateTimeOffset CreatedAt,
    Guid MentorUserId,
    string MentorDisplayName,
    Guid MenteeUserId,
    string MenteeDisplayName,
    IReadOnlyList<MessageDto> Messages);

題名と状態は、画面の上の見出しと、状態の表示になります。

メンターとメンティーの表示名は、「メンター次郎 → メンティー桜」の行です。

Messagesはメッセージの一覧です。1件ずつが、本文・送信日時・送信者名を持つMessageDtoになります。

メンターとメンティーのIDは画面に出ませんが、各メッセージを左右どちらに表示するかを決めるのに使います。

このように、DTOは画面に必要な項目に合わせて作ってあります。

補足として、クエリのDTOは画面ごとに作ります。例えば、トピック一覧の画面には、メッセージを持たない別のDTO(TopicListItemDto)を使います。

public record TopicListItemDto(
    Guid Id,
    Guid MentorshipId,
    string Title,
    TopicStatus Status,
    DateTimeOffset CreatedAt,
    string MentorDisplayName,
    string MenteeDisplayName);
トピック一覧画面

集約をまたいで1回で読む

このトピック詳細画面(図)には、三つの集約から来た値が並んでいます。

題名と状態はTopicから、「メンター → メンティー」はMentorshipとUserから来ています。メッセージはTopicの中にあり、送信者名はUserからです。

詳細画面のDTOを組み立てるのが、TopicQueryServiceのGetByIdAsyncです。

    public async Task<TopicDetailDto?> GetByIdAsync(Guid topicId, CurrentUser currentUser, CancellationToken cancellationToken = default)
    {
        // …(例外処理の殻・2 行省略)
            await using var dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);

            return await dbContext.Topics
                .AsNoTracking()
                .Where(t => t.Id == topicId &&
                    (currentUser.Role == Role.Admin ||
                     t.Mentorship!.MentorUserId == currentUser.UserId ||
                     t.Mentorship.MenteeUserId == currentUser.UserId))
                .Select(t => new TopicDetailDto(
                    t.Id,
                    t.MentorshipId,
                    t.Title,
                    t.Status,
                    t.CreatedAt,
                    t.Mentorship!.MentorUserId,
                    t.Mentorship.MentorUser!.DisplayName,
                    t.Mentorship.MenteeUserId,
                    t.Mentorship.MenteeUser!.DisplayName,
                    t.Messages
                        .OrderBy(m => m.SentAt)
                        .Select(m => new MessageDto(
                            m.Id,
                            m.Content,
                            m.SentAt,
                            m.SenderUserId,
                            m.SenderUser!.DisplayName))
                        .ToList()))
                .FirstOrDefaultAsync(cancellationToken);
        // …(例外処理・7 行省略)
    }

トピックの題名や状態は、Topic集約から読みます。

メンターとメンティーはMentorship集約から、その表示名はさらに先のUser集約から読んでいます。

メッセージの一覧も、同じSelectの中で入れ子のDTOにしています。送信者の表示名は、ここでもUserから取ります。

実際に発行されるSQLをみると、トピックにメンタリングとユーザーがJOINでつながっています。

SELECT ...
FROM (
    SELECT TOP(1) ...
    FROM [Topics] AS [t]
    INNER JOIN [Mentorships] AS [m] ON [t].[MentorshipId] = [m].[Id]
    INNER JOIN [Users] AS [u] ON [m].[MentorUserId] = [u].[Id]
    INNER JOIN [Users] AS [u0] ON [m].[MenteeUserId] = [u0].[Id]
    WHERE [t].[Id] = @topicId AND ([m].[MentorUserId] = @currentUser_UserId OR [m].[MenteeUserId] = @currentUser_UserId)
) AS [s]
LEFT JOIN (
    SELECT ...
    FROM [Messages] AS [m0]
    INNER JOIN [Users] AS [u1] ON [m0].[SenderUserId] = [u1].[Id]
) AS [s0] ON [s].[Id] = [s0].[TopicId]
ORDER BY [s].[Id], [s].[Id0], [s].[Id1], [s].[Id2], [s0].[SentAt], [s0].[Id]

メッセージの一覧もLEFT JOINでつながり、全体が1回の問い合わせです。三つの集約と入れ子の一覧を、まとめて読んでいます。

メッセージを送信日時の順に並べるOrderByも、SQLのORDER BYに入っています。

変更追跡しない

このクエリは、リポジトリも作業単位も通さず、DbContextから直接読みます。

        try
        {
            await using var dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);

            return await dbContext.Topics

GetByIdAsyncにはAsNoTrackingがあります。ただ、このクエリは最後にDTOへ射影するので、AsNoTrackingがなくても変更追跡は起きません。


            return await dbContext.Topics
                .AsNoTracking()
                .Where(t => t.Id == topicId &&
                    (currentUser.Role == Role.Admin ||

それでも書いているのは、読み取り専用のクエリだと一目で分かるようにするためです。


            return await dbContext.Topics
                .AsNoTracking()
                .Where(t => t.Id == topicId &&
                    (currentUser.Role == Role.Admin ||
                     t.Mentorship!.MentorUserId == currentUser.UserId ||
                     t.Mentorship.MenteeUserId == currentUser.UserId))
                .Select(t => new TopicDetailDto(
                    t.Id,
                    t.MentorshipId,

見せてよいものだけ返す

権限の条件は、Whereの中にあります。管理者か、そのトピックのメンタリングに参加している人だけが読めます。

            return await dbContext.Topics
                .AsNoTracking()
                .Where(t => t.Id == topicId &&
                    (currentUser.Role == Role.Admin ||
                     t.Mentorship!.MentorUserId == currentUser.UserId ||
                     t.Mentorship.MenteeUserId == currentUser.UserId))
                .Select(t => new TopicDetailDto(
                    t.Id,

先ほどのSQLは、メンティーで呼んだときのものです。条件がWHERE句に入り、ユーザーIDと比べています。

ロールの判定(currentUser.Role == Role.Admin)はSQLに出てきません。EF CoreがSQLにする前に評価するので、管理者で呼ぶと、この条件ごと消えます。

SELECT TOP(1) ...
FROM [Topics] AS [t]
INNER JOIN [Mentorships] AS [m] ON [t].[MentorshipId] = [m].[Id]
INNER JOIN [Users] AS [u] ON [m].[MentorUserId] = [u].[Id]
INNER JOIN [Users] AS [u0] ON [m].[MenteeUserId] = [u0].[Id]
WHERE [t].[Id] = @topicId

条件に合わなければ結果はnullになり、詳細画面は「指定されたトピックが見つかりません」と表示します。

                            m.SenderUser!.DisplayName))
                        .ToList()))
                .FirstOrDefaultAsync(cancellationToken);
        }
        catch (Exception ex)

参加していないメンティーが開くと、こうなります。「権限がありません」とは出ないので、そのトピックがあることも伝わりません。

参加していないメンティーで開いたトピック詳細画面

このように、権限の条件はクエリの中に書き込みます。弱点は、書き忘れてもエラーにならないことです。

            return await dbContext.Topics
                .AsNoTracking()
                .Where(t => t.Id == topicId &&
                    (currentUser.Role == Role.Admin ||
                     t.Mentorship!.MentorUserId == currentUser.UserId ||
                     t.Mentorship.MenteeUserId == currentUser.UserId))
                .Select(t => new TopicDetailDto(
                    t.Id,

MentorAppは開発ガイドの【認可規約】で、全メソッドにCurrentUserを受け取らせ、書き忘れを減らしています。

    public async Task<TopicDetailDto?> GetByIdAsync(Guid topicId, CurrentUser currentUser, CancellationToken cancellationToken = default)
    {
        try

ただ、規約だけでは足りず、テストも必要です。テストについては、後続のパートで扱います。

補足:条件を一か所にまとめる方法

EF Coreのグローバルクエリフィルターを使うと、絞る条件をエンティティ型ごとに一度だけ登録し、すべてのクエリに自動で付けられます。

失敗を記録する

最後はログです。ここまで省略していた、GetByIdAsyncの例外処理をみます。

    public async Task<TopicDetailDto?> GetByIdAsync(Guid topicId, CurrentUser currentUser, CancellationToken cancellationToken = default)
    {
        try
        {
            // …(クエリ本体・28 行省略)
        }
        catch (Exception ex)
        {
            logger.LogError(ex, "トピックの取得に失敗しました: TopicId={TopicId}, CurrentUserId={CurrentUserId}",
                topicId, currentUser.UserId);
            throw;
        }
    }

DB障害などで読み込みに失敗したら、LogErrorで記録して、例外を呼び出し元へ投げ直します。

コマンド側(第30回の手順(7))は、成功も失敗も記録しました。誰がいつ何を変えたかを、後から確かめられるようにするためです。

    public async Task<Mentorship> CompleteMentorshipAsync(
        Guid mentorshipId,
        CurrentUser currentUser,
        CancellationToken cancellationToken = default)
    {
        try
        {
            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);

            logger.LogInformation(
                "メンタリング関係を完了しました: MentorshipId={MentorshipId}, MentorUserId={MentorUserId}, MenteeUserId={MenteeUserId}, CurrentUserId={CurrentUserId}",
                mentorship.Id, mentorship.MentorUserId, mentorship.MenteeUserId, currentUser.UserId);

            return mentorship;
        }
        catch (Exception ex)
        {
            logger.LogError(ex, "メンタリング関係の完了処理に失敗しました: MentorshipId={MentorshipId}, CurrentUserId={CurrentUserId}", mentorshipId, currentUser.UserId);
            throw;
        }
    }

MentorAppのクエリは状態を変えないので、記録するのは失敗だけです。権限で絞られて消えた行も、失敗ではないので記録しません。

ただ、医療記録や個人情報のように、誰が見たか自体を残す必要がある業務では、読み取りの成功も記録します。

どちらのログにも、操作した人のCurrentUserIdが入ります。

プロ美

DBの失敗なら、EF Coreもログを出すよね?

プロ太

出します。でもEF Coreのログには、誰の操作かがありません。QueryServiceはアプリケーションサービスを通らないので、自分で記録するんです。

まとめ

今回は、CQRSのクエリ側をEF Coreで書くときに、コマンド側と比べて変わる四つのことと、ログの残し方を整理しました。重要なポイントは以下です。

今回のポイント

  • クエリは、集約ではなく画面の形のDTOを返す。DTOは画面ごとに作る
  • 集約をまたいで1回で読む。複数の集約をJOINでつなぐ1回のSQLになる
  • DbContextを直接使い、変更追跡しない。DTOへの射影なら変更追跡はもともと起きない
  • 権限の扱いは変わる場合がある。絞るなら条件はWHERE句に入れ、書き忘れは規約とテストで防ぐ
  • クエリのログは、MentorAppでは失敗だけを、操作した人のIDとともに記録する

次回は、開発や動作確認に使う初期データ(Seedデータ)を扱います。

プロ太

クエリは、画面の形のDTOへ、変更追跡せずに1回で読む。コマンド側との違いが見えてきましたね。

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

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