【C#/Blazor】実務Webアプリ開発編 (32)CQRSのクエリ側をEF Coreで実装する ~画面の形のDTOへ、変更追跡せずに1回で読む~
実務Webアプリ開発編です。今回は、CQRSのクエリ側をEF Coreでどう書くかを、コマンド側と比べながら整理します。
前回は、状態を変えるコマンドと、データを返すクエリを分ける理由をみました。今回は、そのクエリ側の書き方を掘り下げます。
以下のような方に役立つ内容となっています。
この記事が役に立つ方
- CQRSの考え方は分かったが、クエリ側を実際にどう書くか迷っている
- リポジトリを通さずに、何をどう読めばよいのか知りたい
- 権限のないデータを、クエリでどう扱うか整理したい
以下のようなMentorAppを題材として進めます。

GitHubにドキュメント・コードの一式があります。
prota-p/MentorApp: Blazor Server × クリーンアーキテクチャ × DDD の学習用リファレンス実装(実務Webアプリ開発編の題材)
前回のCQRSの考え方と、コマンド側のアプリケーションサービスが土台になります。以下の記事とつなげて読むと理解しやすいです。
前回は入口だけみたクエリ側を、今回は主役にします。コマンド側と何が変わるのかに注目してください。
コマンド側と比べて、クエリ側で変わる四つのこと
前回みたとおり、画面からの要求は、コマンドとクエリで別々の処理へ向かい、同じDBにたどり着きます。

今回の主役は、下のクエリの経路です。上のコマンドの経路と比べると、書き方は何が変わるのでしょうか。
主に変わるのは、次の表の四つです。比べる相手は、第28回〜第30回でみたリポジトリ・作業単位・アプリケーションサービスです。
| コマンド側 | クエリ側 | |
|---|---|---|
| 返すもの | 集約(エンティティ) | 画面の形のDTO |
| 読む範囲 | 集約の内側だけ | 集約をまたいで1回で読む |
| 変更追跡 | 使う(作業単位でまとめて保存する) | 使わない(DbContextから直接読む) |
| 権限の扱い | 変える1件に権限がなければ止める | 止めることも、見せてよい行に絞ることもある |
コマンドは集約を返し、そのルールを通して変更します。クエリは変更しないので、画面に並べる形のDTOを返せば足ります。
コマンドのリポジトリは、集約の内側だけを読み込みました。クエリは守るルールがないので、集約をまたいで1回で読めます。
コマンドは、読み込んだ集約の変更を記録し(変更追跡)、作業単位でまとめて保存します。クエリは保存しないので、変更追跡は要りません。
最後が権限です。第30回のコマンドは、変える1件に権限がなければ、例外を投げて止めました。
クエリでも、画面そのものを使えない人なら止めます。ただ一覧なら、見せてよい行だけに絞って返す扱いもできます。
止めずに絞ったほうがいいのは、どんなとき?
一覧の中に、見せてよい行と見せられない行が混ざっているときです。見せられない行だけを除けば、一覧の画面はそのまま使えます。
四つが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を使って説明します。
この章では、次の順に確かめていきます。最初の四つが、クエリ側で変わる四つのことにあたります。最後はクエリ側のログです。
この章でみること
- 画面の形のDTO
- 集約をまたいで1回で読む
- 変更追跡しない
- 見せてよいものだけ返す
- 失敗を記録する
画面の形の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.TopicsGetByIdAsyncには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アプリの開発について一緒に学んでいきましょう!




