実務Webアプリ開発編です。Part IV(ドメイン層の実装)として、前回はTopic集約を題材に、親子関係を持つ集約で子エンティティをどう守るかを見てきました。

今回はその続きとして、集約を永続化するための保存・取得の入口をどこに置くかを扱います。

特に、リポジトリ(Repository)のインターフェースをなぜDomain層に置くのかを、依存性逆転の原則とあわせて整理していきます。

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

  • リポジトリパターンの役割をDDDやクリーンアーキテクチャの文脈で理解したい
  • Domain層がInfrastructure層へ依存してはいけない理由を実コードで確認したい
  • IUserRepositoryUserRepository の違いがまだ曖昧
  • ユニットオブワーク(Unit of Work)がリポジトリとどう関係するのか知りたい

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

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

前回・前々回のエンティティ・集約の記事から続けて読むと、今回の「集約単位の永続化」を扱うリポジトリの役割がより理解しやすくなると思います。

【C#/Blazor】実務Webアプリ開発編 (20)エンティティ・集約・集約ルートを実コードで理解する ~DDDの不変条件の守り方~ 実務Webアプリ開発編です。前回は、Emailを題材に値オブジェクトの実装を見てきました。 今回はPart IV(ドメイン層の実...
【C#/Blazor】実務Webアプリ開発編 (21)集約境界の決め方と守り方 ~TopicとMessageの親子関係をDDDで実装する 実務Webアプリ開発編です。Part IV(ドメイン層の実装)として、前回はUserやMentorshipを題材に、集約ルート自身の状...
プロ太

データの「永続化」は実務アプリの開発で不可欠な要素ですね。

クリーンアーキテクチャ・DDDにおいて、永続化をどう実現するのかを一緒に学んでいきましょう!

集約の永続化をアプリ層からどう扱いたいか

前回までで、値オブジェクト・エンティティ・集約といったDomain層の実装を進めてきました。ただし集約が作れても、それだけではアプリとして動きません。

作った集約をDBに保存し、必要なときに取り出す仕組みが必要です。この「永続化」をどう扱うかが、今回のテーマです。

まず、Application層(ユースケース)の側から「こう使いたい」というイメージを先に整理します。

たとえば、TopicにMessageを投稿するユースケースを日本語で書くと、次のようなかたちになります。

永続化機構から topic を取得する(TopicId 指定)
topic.PostMessage(...)でメッセージを投稿する
topicへの変更を永続化機構で保存する

Application層がやりたいことは「Topicを取得してメッセージを投稿する」なので、DBのテーブル構成やSQL、EF Coreの操作はアプリ層からは見えなくしたいところです。

DBの都合がApplication層に漏れると、永続化の方法を変えるたびにユースケースのコードまで修正が広がりやすくなるためです。

この「永続化機構」の正体を次のセクションで整理します。DDDとクリーンアーキテクチャでは、リポジトリユニットオブワークという仕組みを使ってこれを実現します。

リポジトリとユニットオブワークの役割

リポジトリ — 集約を保存・取得する窓口

さきほどの疑似コードにおける「永続化機構から topic を取得する」と「topicへの変更を永続化機構で保存する」を担うのがリポジトリです。

DDDでは、リポジトリをDB操作そのものではなく、集約を出し入れするための抽象として考えます。そして、リポジトリは集約単位で用意するのが基本です。

集約の形集約ルート子エンティティリポジトリ
エンティティ単体の集約UserなしUserリポジトリ
親子関係エンティティを持つ集約TopicMessageTopicリポジトリ

Messageを操作したいときも、まずTopic集約を取得するのが基本です。

プロ太

前回記事でも説明した通り、外部から集約を操作するときには、集約ルートを必ず窓口とすることで、集約内の不変条件を守らせるのでしたね。

なので、リポジトリの単位も集約ごととして、子エンティティを個別に勝手に永続化する経路は作らないようにするのです。

ユニットオブワーク — 変更をまとめて保存する境界

リポジトリが「どの集約を扱うか」を表すなら、ユニットオブワーク「いつ保存を確定するか」を表す境界です。

先ほどの疑似コードをC#でイメージすると、次のようになります。

await using var uow = await unitOfWorkFactory.CreateAsync(cancellationToken);

var topic = await uow.Topics.FindByIdAsync(topicId, cancellationToken);
topic.PostMessage(senderUserId, content, now); //ここでまだ永続データの変更は確定してない

await uow.SaveChangesAsync(cancellationToken); //ここで永続データの変更が確定

uow.Topics がリポジトリへの入口、SaveChangesAsync() がユースケース全体の変更をまとめて確定する口です。

集約のメソッド(PostMessage() など)を呼び出しだけでは、まだ永続データの変更・保存は確定しません。ユニットオブワークを使って確定させます。

ユニットオブワークには、トランザクション境界、DbContext 共有、Factoryによる生成、破棄タイミングなどの話も含まれます。

これらはシリーズ後段のInfrastructure層の説明で詳しく扱います。

今回はざっくり、複数のリポジトリを同じ永続化コンテキストの中で扱い、最後に保存タイミングをまとめる境界、という理解で大丈夫です。

クリーンアーキテクチャにおける実装方法

リポジトリとユニットオブワークを、クリーンアーキテクチャの中でどう実装するかを見ていきます。

クリーンアーキテクチャの基本とコード上の依存方向、そして実行時の呼び出し方向の逆転(依存性逆転の原則)については、以下の記事も参考にしてください。

【C#/Blazor】実務Webアプリ開発編 (15)クリーンアーキテクチャ入門 ~4層構成の役割と依存方向のルール~ 実務Webアプリ開発編です。前回まではPart II(要件定義と設計)で、MentorApp の要件定義と設計を通して、何を作るかを整...
【C#/Blazor】実務Webアプリ開発編 (18)クリーンアーキテクチャの処理の流れをデバッガで理解する ~画面クリックからDBまで全4層を追う~ 実務Webアプリ開発編です。前回まででアーキテクチャ概論として以下を学びました。 クリーンアーキテクチャ・ドメイン駆動設計(...

コードの依存方向のルールを思い出す

クリーンアーキテクチャでは、内側の層ほどアプリの本質に近く、外側の技術詳細に依存しない形を目指します。Domain層はその中心です。

そのため、コード上の依存の向きは、外側→内側となるルールでした。

今回、Application層は永続化データを扱う入口であるリポジトリを使いたいわけですね。そして、永続化機構の実体(例:TopicRepository)はInfrastructure層に置かれます。

ここで、Infrastructure層の具体的な TopicRepository をApplication層から直接参照してしまうと、依存方向のルールが崩れてしまいます。

そこで、Application層が参照するために、ITopicRepository などのインターフェースを内側のDomain層に置き、Infrastructure層で、そのインターフェースを実装します。

その結果、Application層もInfrastructure層も、Domain層にある抽象へ依存し、依存方向は「外側→内側」となります。

インターフェースと実装クラス

この依存方向ルールの考え方で、リポジトリとユニットオブワークをインターフェース(抽象)実装クラス(具象)に分けて実装します。

src/
  MentorApp.Domain/              ← Domain層
    ...
    IUserRepository.cs           ← インターフェース(約束)
    ...
    IUnitOfWork.cs
    ...
  MentorApp.Infrastructure/      ← Infrastructure層
    ...
    UserRepository.cs            ← 実装クラス(具体的なDB操作)
    ...
    DbUnitOfWork.cs
    ...

少し具体的な例として、Topic集約のリポジトリをみてみましょう。

Domain層に置くのはインターフェースです。「何ができるか」という約束をC#の interface で表します。

public interface ITopicRepository
{
    public Task<Topic?> FindByIdAsync(Guid id, CancellationToken cancellationToken = default);
   ...
}

ここにあるのは「Topicをどう探せるか」「どう追加できるか」という操作だけです。EF CoreやSQL、DbSet といった具体的な実装技術は出てきません。

Infrastructure層の実装クラスがその約束を満たす形でDBアクセスを実装します。例えば、必要に応じてEF Coreなどを使ってDBから取得します。

internal sealed class TopicRepository(AppDbContext dbContext) : ITopicRepository
{
    public async Task<Topic?> FindByIdAsync(
        Guid id,
        CancellationToken cancellationToken = default)
    {
        // ここで、例えばEF Coreを使ってDBからTopic集約を取得する
    }
    ...
}

呼び出し方向と依存方向は逆になる

たとえば Application 層の TopicService.PostMessageAsync() では、インターフェースを通じてリポジトリを呼び出しています。

// TopicService.PostMessageAsync() の一部
var topic = await uow.Topics.FindByIdAsync(request.TopicId, cancellationToken);
topic.PostMessage(request.SenderUserId, request.Content, now);
await uow.SaveChangesAsync(cancellationToken);

実行時には、Application層からInfrastructure層の実装へ向かってメソッドが呼ばれます。ですが、コード上の依存は逆です。

■実行時の呼び出し
TopicService.PostMessageAsync()【Application層からの呼び出し】
  ↓ ITopicRepository.FindByIdAsync() 【Domain層のインターフェイス】
TopicRepository 【Infrastructure層の実装クラス】
  ↓
DBアクセス

■コード上の依存
Application層 → Domain層の ITopicRepository
Infrastructure層 → Domain層の ITopicRepository を実装

この構造のおかげで、Application層は TopicRepository 具象クラスを知らずに済みます。知っているのは ITopicRepository だけです。

インターフェースと具体的実装クラスの紐づけ(例:ITopicRepositoryTopicRepository)は、DIコンテナの仕組みを使いWeb層で配線を行います。

プロ美

コード上は、Application層はドメイン層における「約束」(インターフェース)にだけ依存していて、Infrastructure層の詳細実装を知らないんだね。

プロ太

そうですね。こうしておくとInfrastructure層で変更があったときに、Application層まで影響が及びにくくなります。

リポジトリを例に説明しましたが、ユニットオブワークについても同様で、インターフェイスと具体的な実装クラスで分けています。

MentorAppの実装をみる

ここからは実際のMentorAppのコードを見ます。「インターフェースはDomain層、実装はInfrastructure層」の考え方が、そのまま配置にも表れています。

Domain層の配置

リポジトリインターフェースとユニットオブワーク関連は、Domain層の次の場所に置かれています。

src/MentorApp.Domain/
  Models/
    Users/
      IUserRepository.cs
    Mentorships/
      IMentorshipRepository.cs
    Topics/
      ITopicRepository.cs
    Shared/
      IUnitOfWork.cs
      IUnitOfWorkFactory.cs

一方、実装クラスはInfrastructure層にあります。つまり、Domain層にあるのは「何ができるか」という約束で、「どうやるか」はInfrastructure層の責務です。

プロ太

それでは、リポジトリ・ユニットオブワークのインターフェース、そしてこれらがApplication層からどう使われるかを順番にみていきましょう。

リポジトリのインターフェース

IUserRepository.cs は、リポジトリインターフェースの基本形として読みやすいです。

public interface IUserRepository
{
    public Task<User?> FindByIdAsync(Guid id, CancellationToken cancellationToken = default);

    public Task<User?> FindByExternalIdAsync(string externalId, CancellationToken cancellationToken = default);

    public Task AddAsync(User user, CancellationToken cancellationToken = default);
}

FindByIdAsync() は編集やロール変更などで既存Userを取得し、FindByExternalIdAsync() で外部認証IDをキーとして既存ユーザーを取得します。

そして、AddAsync() で新規ユーザの追加を行います。保存の確定については別途ユニットオブワークを使って行うことになります。

プロ美

リポジトリって、コレクションに対する操作をしているみたいだね。

プロ太

そうですね。リポジトリは、集約ルートを要素とするコレクションのように見える永続化アクセス口なのです。

集約の取得・追加・削除はリポジトリを通じて行い、取得した集約に対する変更は、ユースケースの最後にユニットオブワークにより確定します。

もう一つ、ITopicRepository.cs では、前回見たTopic集約の扱い方がそのまま見えてきます。

public interface ITopicRepository
{
    public Task<Topic?> FindByIdAsync(Guid id, CancellationToken cancellationToken = default);

    public Task<Topic?> FindByMentorshipAndTitleAsync(
        Guid mentorshipId,
        string title,
        CancellationToken cancellationToken = default);

    public Task AddAsync(Topic topic, CancellationToken cancellationToken = default);

    /// <summary>指定した Mentorship に Topic が1件以上存在するかを返す。削除可否チェックに使用。</summary>
    public Task<bool> HasAnyByMentorshipIdAsync(Guid mentorshipId, CancellationToken cancellationToken = default);
}

FindByIdAsync() は、Topicを開く、Messageを投稿する、Closeするといった操作を行う際に、対象のTopicを取得するために使います。

取得した集約に対して、続けてTopic集約のメソッド(PostMessage()など)を呼び出して状態を変更します。

FindByMentorshipAndTitleAsync() は、同じMentorship内でのタイトル重複チェックに使います。

HasAnyByMentorshipIdAsync() は、指定MentorshipにTopicが存在するかを見るためのメソッドです。削除可否の判定に使う意図がコメントにも書かれています。

ここで重要なのは、「IMessageRepository がない」ことです。MessageはTopic集約内の子エンティティなので、リポジトリの入口もTopic側に寄せています。

プロ太

IMentorshipRepository.cs も同様の構造です。次回のドメインサービス(MentorshipDuplicationCheckService など)で使われます。

アプリが永続データを扱う操作には、大きく2種類あります。

  • コマンド:状態を変える(書き込み)操作
  • クエリ:状態を読み取る(読み込み)操作

この2つを明確に分けて設計する考え方を、コマンド・クエリ責務分離(CQRS)といいます。MentorAppはこの考えに基づいた設計になっています。

今回紹介したリポジトリは「コマンド側」の仕組みです。集約を取得して状態を変更し、ユニットオブワークで保存を確定します。

一方、一覧取得や画面表示向けのデータ取得は「クエリ側」の仕事です。

集約の境界にとらわれず、複数テーブルを結合して必要な形にデータをまとめることが多く、リポジトリとは別の仕組みを使います。

クエリ側の実装方法については、このシリーズで別途解説します。

ユニットオブワークのインターフェース

IUnitOfWork.cs には、リポジトリ群への入口と保存の確定タイミングがまとまっています。

public interface IUnitOfWork : IAsyncDisposable
{
    public IUserRepository Users { get; }

    public IMentorshipRepository Mentorships { get; }

    public ITopicRepository Topics { get; }

    public Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
}

UsersMentorshipsTopics から各リポジトリへアクセスし、最後に SaveChangesAsync() で一連の変更をまとめて保存します。

また、IAsyncDisposable を実装しているので、ユースケース終了時に関連リソースを非同期に破棄できます。

対応する IUnitOfWorkFactory には CreateAsync() があり、Application層はそこから新しいユニットオブワークを作ります。

public interface IUnitOfWorkFactory
{
    public Task<IUnitOfWork> CreateAsync(CancellationToken cancellationToken = default);
}
プロ太

ユニットオブワークは、1回のユースケースごとに新しく作ります。

内部では「今回取得・変更した集約」などを覚えているため、処理ごとに使い回さず、新しい作業単位として扱います。

アプリ層からの使われ方

TopicService.csPostMessageAsync() を見ると、Application層が具象クラスを知らずに仕事をしていることがよく分かります。
(varの箇所も、わかりやすさのため一部型名を明記しています。)

await using IUnitOfWork uow = await unitOfWorkFactory.CreateAsync(cancellationToken);

Topic topic = await uow.Topics.FindByIdAsync(request.TopicId, cancellationToken)
    ?? throw new KeyNotFoundException($"トピックが見つかりません: {request.TopicId}");

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

var now = timeProvider.GetUtcNow();
var message = topic.PostMessage(request.SenderUserId, request.Content, now);

await uow.SaveChangesAsync(cancellationToken);

流れはとても素直です。まずFactoryからユニットオブワークを作り、リポジトリ経由で集約を取得し、集約のメソッドを呼びだして操作を行い、最後に保存しています。

ここでApplication層が知っているのは IUnitOfWorkFactoryIUnitOfWork、そして各リポジトリインターフェースだけです。

前半で見た「取得 → 集約メソッド呼び出し → SaveChanges」という一般形が、MentorAppでもそのまま実装されています。

リポジトリ・ユニットオブワークの抽象化は本当に必要?

プロ美

EF Coreを使っていると、DbContext がユニットオブワーク、DbSet がリポジトリみたいな役割を持っていているよね。

EF Coreだけで技術詳細(DB)の差し替えもできるし、わざわざ自分でインターフェースを定義するのって、やりすぎじゃないのかな?

プロ太

良い質問ですね。確かに、EF Coreの抽象化は強力で、DBを入れ替えることもある程度容易になっていますね。

実際、「EF Core上にリポジトリを被せるのは冗長だ」という意見もあります。

ただ、抽象化する本当の理由は「DBを差し替えやすくする」ためよりも、Domain/Application層をEF Coreの都合から守ることにあります。

EF Coreを使っている場合、DbContext 自体がユニットオブワークに近く、DbSet もリポジトリに近い役割を持っています。

そのため、リポジトリとユニットオブワークのインターフェースは、技術的には必須ではありません。

それでもインターフェースを置く理由は、EF Coreの機能不足を補うためではなく、Domain/Application層とInfrastructure層の境界を明確にするためです。

このとき重要なのは、リポジトリが IQueryableDbSet ではなく Topic のような集約そのものを返すことです。

EF Coreの型を境界の外に漏らさないことで、初めて境界が実際に機能します。

インターフェースを挟むことで、Application層は「どの集約を取得し、どう操作し、いつ保存を確定するか」だけに集中でき、DB固有の記述が消えて業務の流れがそのまま現れます。

また、テスト時にはリポジトリをモックに差し替えられるため、DBなしでユースケースのロジックだけを検証できるという恩恵も生まれます。

これがMentorAppでもリポジトリを抽象化している本質的な理由です。

まとめ

今回、クリーンアーキテクチャ・DDDにおいて永続化を扱うためのリポジトリ・ユニットオブワークについて学びましや。今回のポイントを整理すると次の通りです。

  • リポジトリは集約を保存・取得するための窓口となる
  • リポジトリはテーブル単位ではなく集約単位で用意する
  • Domain層には実装ではなくインターフェースを置くことでコードの依存方向を守る
  • Application層はインターフェースに依存し、Infrastructure層が実装する
  • ユニットオブワークは複数リポジトリと保存タイミングをまとめる境界となる

次回は、ドメインサービスを実装します。MentorshipDuplicationCheckServiceRoleChangeValidationService を例とします。

1つの集約だけでは判断しにくいビジネスルールを、Domain層にどう表現するかを見ていきます。

プロ太

引き続き、クリーンアーキテクチャ・DDDに基づいてドメイン層をどのように実装するかについて、一緒に学んでいきましょう。

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