実務Webアプリ開発編です。今回のテーマは、DDD(ドメイン駆動設計)のリポジトリパターンをEF Coreで実装する方法です。

「Repositoryクラスの中に何を書くか」を、実際に動くWebアプリのコードと設計判断から読み解きます。

シリーズとしては、前回まででEF Coreのマッピングが完成し、今回は第22回でDomain層に定義したリポジトリインターフェースへ、Infrastructure層で実装を与える回です。

今回のポイントは次の3つです。

  • EF CoreのコードはInfrastructure層のリポジトリ実装に閉じ込める(Application層はインターフェースだけを見る)
  • リポジトリは集約単位で作り、取得時も同一集約の範囲だけを読み込む(関連するデータをどこまで一緒に読むかは、集約境界で決める)
  • リポジトリは変更をDbContextに覚えさせるだけ(DBへの反映は、最後に1回まとめて行う)

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

  • DDDの集約・リポジトリという設計が、実装コードでどう形になるのか知りたい
  • クリーンアーキテクチャにおける依存性逆転の実例をみたい
  • Repositoryクラスの中に何を書けばよいか分からない
  • CQRS(コマンド・クエリ責務分離)におけるコマンド側の考え方を知りたい
  • Includeをどこまで付けるべきか、毎回迷う

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

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

今回の内容は、リポジトリインターフェースの定義と、集約境界の設計が土台になります。以下の記事とつなげて読むと理解しやすいです。

【C#/Blazor】実務Webアプリ開発編 (22)DDDのリポジトリとユニットオブワークで集約を保存・取得する ~依存性逆転の原則を学ぶ~ 実務Webアプリ開発編です。Part IV(ドメイン層の実装)として、前回はTopic集約を題材に、親子関係を持つ集約で子エンティティ...
【C#/Blazor】実務Webアプリ開発編 (21)DDDの集約境界の決め方と守り方 ~TopicとMessageの親子関係を学ぶ~ 実務Webアプリ開発編です。Part IV(ドメイン層の実装)として、前回はUserやMentorshipを題材に、集約ルート自身の状...
プロ太

第22回で「何ができるか」という約束(インターフェース)は決めました。今回はその約束に、EF Coreで実装を与えます。

実装の前に:リポジトリの役割を整理する

実装コードを読む前に、リポジトリの役割を次の3つに分けて整理しておきます。

  • 復習:なぜインターフェースをDomain層に置くのか(依存性逆転)
  • 視点①:リポジトリは何のための入口か(コマンドとクエリ)
  • 視点②:リポジトリに何のメソッドを持たせるか(ユースケースの要求)

復習:なぜインターフェースをDomain層に置くのか

マッピングが完成したAppDbContextですが、これをApplication層から直接使うと、Application層がEF Coreという外部技術へ依存してしまいます。

プロ太

クリーンアーキテクチャでは以下のような依存の向きを遵守するのでしたね。

第22回でリポジトリのインターフェース(=「何ができるか」の約束)をDomain層に定義したのは、この向きを守るためでした。

Application層が書くのはインターフェース名だけ。EF Coreを使う実装クラスの側が、それを実装するためにDomain層を参照します。

図のとおり、コード上の依存は外→内の一方向。一方、実行時に動くのは外側の実装クラスの実体なので、処理の流れは内→外です。

ふつうコード上の依存は、呼び出しと同じ内→外になります。それを外→内へ反転させるので依存性逆転と呼びます。

今回書くのは図の右側、EF Coreを知る唯一の場所であるInfrastructure層の実装クラスになります。

プロ太

DbSetのようなEF Coreの型が外へ漏れないことは、インターフェースの定義時点で保証されています。

だから外側の層はDBの詳細を知らずに済むんですね。

視点①:リポジトリは書き込み側の入口 ~コマンドとクエリ~

アプリケーションのDBアクセスは、目的で大きく2種類に分けられます。

  • コマンド(書き込み):状態を変更する。集約を取得し、集約のメソッドで変更し、保存する
  • クエリ(読み取り):画面表示向けにデータを取得する。一覧・検索・複数テーブルの結合など形は自由

この2つを別の責務として分けて設計する考え方が、第22回で少し触れたCQRS(コマンド・クエリ責務分離)です。

そのうえで、リポジトリをどちらに位置づけるかには、大きく2つの流儀があります。

  • コマンド側の入口に絞る:リポジトリは集約の取得・変更だけを担当し、画面向けの読み取りは別の部品に任せる
  • 読み取りもリポジトリに集約させる:一覧取得や検索用のメソッドも、同じリポジトリに持たせる

どちらが正解ということはなく、アプリケーションの規模や方針で選ぶものです。

そしてMentorAppは前者、コマンド側の入口に絞る設計を採用しています。以降の説明は、この前提に立ったものです。

この前提で注意したいのは、コマンド側にも「読み取り」自体はあることです。

変更対象の集約をIDなどのキーで取得する読み取りは、書き込みの前処理としてリポジトリが担います。

一方、リポジトリにないのは、様々な条件での一覧取得や集計、画面向けの結合といった読み取りです。それらはクエリ側の担当です。

プロ美

じゃあ、取得系のメソッドはあっても、「いろんな条件で一覧を取ってくるメソッド」はないんだね。

プロ太

無いですね。そうした読み取りはクエリ側のクエリサービスという別の入口が担当します。(この記事シリーズでも紹介します)

リポジトリは「集約を取得して変更し、保存する」ためだけの入口となります。

視点②:メソッドはユースケースの要求から決める

2つ目の視点は「リポジトリに何のメソッドを持たせるか」です。全エンティティに同じメソッド一式を機械的に用意するという発想ではありません。

原則は次の2つです。

  • リポジトリは集約ルートごとに1つ作る
  • メソッドは、リポジトリを使う「ユースケースが集約へ何を求めるか」を基準として決める

この原則の結果、同じ「リポジトリ」でも、集約ごとに持っているメソッドは異なります。

MentorAppで実際にどんなリポジトリがあり、メソッドがどう違うのかは、この後、実物のコードで確認していきます。

MentorAppの実装をみる

ここからMentorAppのコードをみていきます。Domain層のインターフェースと、Infrastructure層の実装は次のように対応しています。

src/
├─ MentorApp.Domain/Models/           … インターフェース(約束)
│   ├─ Users/IUserRepository.cs
│   ├─ Mentorships/IMentorshipRepository.cs
│   └─ Topics/ITopicRepository.cs
└─ MentorApp.Infrastructure/Persistence/
    └─ Repositories/                  … EF Coreによる実装
        ├─ UserRepository.cs
        ├─ MentorshipRepository.cs
        └─ TopicRepository.cs

ツリーを見ると、Domain層のインターフェースと、それに対応するInfrastructure層の実装が存在していることがわかります。

リポジトリはUser / Mentorship / Topicという集約ルートに対応して存在し、Topic集約の子であるMessageには専用リポジトリがありません。

この章では、次の3点を順に確認します。

  • Domain層の契約がEF Coreのコードへどう翻訳されるか
    (例:UserRepository・MentorshipRepository)
  • 取得時に同一集約と別集約をどう分けるか
    (例:TopicRepository)
  • 追加・削除・更新を保存確定前の変更としてどう扱うか
    (例:Mentorshipの削除)
プロ太

リポジトリはコマンド側の入口であり、集約を取得して変更する役割」(様々な条件の一覧取得ではない)という点も意識して見ていきましょう。

UserRepositoryで基本形を読む ~契約をEF Coreへ翻訳する~

まずは一番シンプルなUserから、実装の基本形を確認します。Domain層のIUserRepositoryは、次の3つのメソッドだけを約束しています。

  • FindByIdAsync(Guid id):IDでUserを取得する
  • FindByExternalIdAsync(string externalId):外部ID(認証プロバイダのID)でUserを取得する
  • AddAsync(User user):新しいUserを追加する

この約束をInfrastructure層のUserRepositoryが実装します。全文でもこれだけの薄さです。

// UserRepository.cs(Infrastructure層)
internal sealed class UserRepository(AppDbContext dbContext) : IUserRepository
{
    public async Task<User?> FindByIdAsync(Guid id, CancellationToken cancellationToken = default)
        => await dbContext.Users.FindAsync([id], cancellationToken);

    public async Task<User?> FindByExternalIdAsync(string externalId, CancellationToken cancellationToken = default)
        => await dbContext.Users
            .FirstOrDefaultAsync(u => u.ExternalId == externalId, cancellationToken);

    public async Task AddAsync(User user, CancellationToken cancellationToken = default)
        => await dbContext.Users.AddAsync(user, cancellationToken);
}

1メソッドずつ、契約がEF Coreへどう翻訳されているかを見てみましょう。

FindByIdAsyncは主キーによる検索なので、主キー専用のFindAsyncを使います。

FindByExternalIdAsyncは主キー以外の条件なので、FirstOrDefaultAsyncで条件検索します。

AddAsyncは、新しいUserをDbSetへ追加します。ただしこの時点でDBへINSERTが実行されるわけではありません。あくまで「追加対象として登録する」だけです。

プロ太

「追跡対象として登録するだけ」の意味と、DBへ書き込まれるタイミングは、最後のセクションで説明します。

コンストラクタのAppDbContextを「誰がどうやって渡すのか」も含めて、次回のユニットオブワークにつながる一連の話です。

基本形の締めくくりとして、リポジトリのメソッドが「ユースケースの問い」から生まれる例を1つ見ておきます。MentorshipRepositoryにある存在確認メソッドです。

// MentorshipRepository.cs(抜粋)
internal sealed class MentorshipRepository(AppDbContext dbContext) : IMentorshipRepository
{
    public async Task<bool> HasActiveMentorshipAsync(
        Guid mentorUserId,
        Guid menteeUserId,
        CancellationToken cancellationToken = default)
        => await dbContext.Mentorships
            .AnyAsync(
                m => m.MentorUserId == mentorUserId
                    && m.MenteeUserId == menteeUserId
                    && m.Status == MentorshipStatus.Active,
                cancellationToken);
    // ...
}

「このメンターとメンティーの間に、進行中のMentorshipがあるか?」という業務の問いが、そのままメソッド名になっています。

実装はAnyAsyncです。答えに集約の中身は必要ないので、行を丸ごと取得せず存在の有無(bool)だけをDBへ問い合わせます

プロ美

リポジトリに機械的にCRUDのメソッドを実装するのではなく、「ユースケースで求められるもの」を実装するってことだね!

ただし「進行中なら作成禁止」といった業務判断そのものはリポジトリに書きません

リポジトリは事実を取得するまでの役割で、判断はドメインサービスやApplication層の仕事です。他の存在確認メソッドも、すべてこれと同じ考え方で作られています。

Topic集約を正しい範囲で取得する

第21回では、TopicとMessageを「常に一緒に扱う1つのまとまり(同一集約)」と決めました。その境界の判断が、取得コードにどう現れるかを見ます。

鍵になるのはIncludeです。第26回で見たとおり、EF Coreの取得は既定では対象のエンティティだけを読み、関連先を一緒に読み込むにはIncludeで明示的に指定します。

では、Includeでどの関連を指定すべきでしょうか。TopicRepositoryの取得メソッドはこうなっています。

// TopicRepository.cs(抜粋)
internal sealed class TopicRepository(AppDbContext dbContext) : ITopicRepository
{
    public async Task<Topic?> FindByIdAsync(Guid id, CancellationToken cancellationToken = default)
    {
        // ①同一集約内のMessagesはIncludeする(更新時に必要)
        // ②別集約(Mentorship→User)はIncludeしない
        return await dbContext.Topics
            .Include(t => t.Messages.OrderBy(m => m.SentAt))
            .FirstOrDefaultAsync(t => t.Id == id, cancellationToken);
    }
    // ...
}

以下のような範囲で取得しています。

注目してほしいのは、IncludeMessagesの1つだけという点です。これは集約境界に沿った判断です。コード中のコメント①②の順に見ていきましょう。

①同一集約のMessagesはIncludeする

MessageはTopic集約の中の子エンティティです。第21回で見たとおり、Messageはtopic.PostMessage(...)を通じてだけ追加でき、集約全体でビジネスルールを守ります。

集約のルールを正しく判定するには、集約を1つのまとまりとして復元する必要があります。だから、更新のためのFindByIdAsyncではMessagesも一緒に読み込むのです。

OrderBy(m => m.SentAt)は、メッセージを送信順に並べてコレクションへ読み込むための指定です。

プロ太

そして、Messageに専用リポジトリはありません。

子エンティティを単独の保存入口にしないことで、「Topic経由でしか操作できない」という集約の約束が保たれます。

②別集約はIncludeしない

一方、TopicにはナビゲーションプロパティMentorshipがあり、その先にはUserもあります。しかし、これらはIncludeしません。別の集約だからです。

同じ理由で、Messageが持つ送信者への参照(SenderUser)もIncludeしません。Userは別の集約だからです。

別集約が必要な場面では、IDを使ってそれぞれのリポジトリから独立に取得します。

Includeの判断基準は「関連があるか」ではなく「同じ集約か」です。「更新に必要なまとまり=集約」だけを読み込むわけですね。

プロ美

でも、「画面にトピックとその配下のメッセージ一覧を、それぞれのメッセージの投稿者名とあわせて表示したい」とかって場合はどうするの?

そういう時って、別集約であっても必要な関連データをIncludeして一括で取得できた方が便利だよね?

プロ太

それはリポジトリで扱う集約の更新(コマンド)ではなく、表示のための読み取り(クエリ)です。

前半で補足したCQRSの分担のとおり、リポジトリの役割はあくまで集約の更新(コマンド)」なのです。

取得から保存までの一連の流れ

最後に、リポジトリを使った書き込みの一連の流れを確認します。実はこの流れ、第25〜27回のミニプロジェクトで何度も見てきました(例:第27回のコード)。

エンティティを操作して、最後にSaveChangesAsyncを呼ぶ——DBが書き換わるのは、この瞬間でしたね。

MentorAppも同じパターンです。前回も登場したMentorship削除のコードを、流れが見える形でもう一度見てみます。

コード中のuowユニットオブワークと呼ばれる部品で、内部に1つのDbContextを持っています(詳しくは次回扱います)。

// MentorshipService.cs(Application層・抜粋)
// (1) ユニットオブワーク(uow)を用意する
await using var uow = await unitOfWorkFactory.CreateAsync(cancellationToken);

// (2) リポジトリで集約を取得し、変更を登録する
var mentorship = await uow.Mentorships.FindByIdAsync(mentorshipId, cancellationToken)
    ?? throw new KeyNotFoundException(...);
// ...(業務ルールの確認)
uow.Mentorships.Delete(mentorship);

// (3) たまった変更をまとめてDBへ反映する
await uow.SaveChangesAsync(cancellationToken);

書き込みの基本形は、コード中のコメント(1)〜(3)の3ステップです。

  • (1) ユニットオブワーク(=1つのDbContext)を用意する
  • (2) リポジトリで集約を取得し、変更を登録する(ドメインメソッドによる変更や、AddAsyncDelete
  • (3) SaveChangesAsyncで、たまった変更をまとめてDBへ反映する

このうちリポジトリが担うのは(2)だけ、つまりDbContextに変更を覚えさせるところまでです。

上のDeleteの実装はdbContext.Mentorships.Remove(mentorship)の1行で、削除対象として登録するだけ。AddAsyncも同様に、追加対象として登録するだけです。

プロ美

(2)ではドメインのメソッドを呼んで集約を変えるだけだよね?どうして(3)で「何が変わったか」が分かってDBに反映できるの?

プロ太

それは、EF Coreの変更追跡(Change Tracking)という仕組みで実現しています。

DbContextは自分が取得したエンティティを覚えていて、(2)での変更を追跡しています。

(3)のSaveChangesAsyncは、その追跡記録を使って「取得したときから何が変わったか」を検出し、変更を一気にDBへ保存します。

だから、リポジトリ自体にはDBへ変更を明示的に伝えるUpdateのようなメソッドは必要ないのです。

新しいMessageの追加も同じです。追跡中のTopicへPostMessageで子を足せば、集約内の変更として検出されるため、Message専用のAddAsyncは不要です。

この(1)〜(3)の一連の流れを1つの単位として管理する部品が、先ほどのコードに登場したuow——ユニットオブワークです。

複数のリポジトリにまたがる変更も1か所で確定できます。その正体と仕組みは、次回じっくり扱います。

まとめ

今回は、第22回で定義したリポジトリインターフェースに、EF Coreによる実装を与えました。重要なポイントは以下です。

  • EF CoreのコードはInfrastructure層に閉じ込める。外側の層はインターフェースだけを見る
  • リポジトリは集約ルートごとの入口。条件指定の一覧取得などはクエリサービスとの分担
  • Includeの基準は集約境界。同一集約は一緒に読み、別集約は読まない
  • リポジトリは変更をDbContextに覚えさせるだけ。DBへの反映はSaveChangesAsyncで1回で行う
  • 更新は変更追跡に任せる。だからリポジトリ個別にDB反映用のUpdateメソッドは不要

次回は、ユニットオブワークを実装します。複数のリポジトリが同じDbContextを共有する仕組みと、SaveChangesAsyncによる保存確定を扱います。

プロ太

集約境界の設計が、削除ルールに続いて取得コードにも現れました。設計の判断が実装の各所につながっていく感覚、つかめてきたでしょうか。

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

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