実務Webアプリ開発編です。今回のテーマは、DDD(ドメイン駆動設計)において、ユニットオブワーク(Unit of Work)をEF Coreで実装する方法です。

シリーズとしては、前回でリポジトリの実装が完成しました。今回は、複数のリポジトリによる変更をいつ・どこでまとめて確定するかを担う部品をつくります。

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

  • ユニットオブワークという言葉は聞くが、何をする部品なのかピンとこない
  • EF CoreのDbContextとユニットオブワークの関係を整理したい
  • 複数のリポジトリにまたがる変更を、どうまとめて保存するのか知りたい
  • Blazor ServerでDbContextの寿命をどう扱えばよいのか知りたい

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

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

リポジトリのインターフェース定義と、その実装が土台になります。以下の記事とつなげて読むと理解しやすいです。

【C#/Blazor】実務Webアプリ開発編 (22)DDDのリポジトリとユニットオブワークで集約を保存・取得する ~依存性逆転の原則を学ぶ~ 実務Webアプリ開発編です。Part IV(ドメイン層の実装)として、前回はTopic集約を題材に、親子関係を持つ集約で子エンティティ...
【C#/Blazor】実務Webアプリ開発編 (28)リポジトリパターンをEF Coreで実装する ~コマンド側の集約取得と保存入口を作る~ 実務Webアプリ開発編です。今回のテーマは、DDD(ドメイン駆動設計)において、リポジトリパターンをEF Coreで実装する方法です。...
プロ太

前回のコードに出てきたuow——「詳しくは次回」と言っていた、あの部品です。今回はその中身を作ります。

なぜリポジトリだけでは保存が完結しないのか

前回みたとおり、リポジトリは集約を取得し、変更を登録するところまでを担当しました。DBへ実際に書き込むのは、リポジトリの仕事ではありませんでしたね。

それを引き受けるのがユニットオブワーク(Unit of Work)です。前回のコードにもuowという変数名で少しだけ登場していました。

名前のとおり「作業単位」を表す部品です。ユースケース1回ぶんがその単位で、その中で起きた変更を覚えておき、最後にまとめて保存を確定します

MentorAppとEF Coreはいったん離れ、「商品を注文する」という題材でイメージをつかみましょう。注文では在庫を1つ減らし、注文を1件追加します。扱う対象が2つあるので、リポジトリも2つ登場します。

図1: リポジトリ操作を一つの保存へまとめる

図1の4段階には、記事全体を通して次の呼び名を使います。

  • (1) 作成:ユニットオブワークを作る
  • (2) 蓄積:同じユニットオブワークの中で操作し、変更を蓄える
  • (3) 保存:蓄えた変更をまとめて保存する
  • (4) 破棄:ユニットオブワークを破棄し、作業単位を終える

同じ流れは、コードでは次のように現れます。

// 「商品を注文する」ユースケース(役割を示すための疑似コード。実在のコードではありません)
using (var uow = unitOfWorkFactory.Create()) // (1) 作成
{
    // (2) 蓄積:同じユニットオブワーク内で複数の変更を蓄える
    var product = uow.Products.FindById(productId);
    product.DecreaseStock(quantity);
    uow.Orders.Add(new Order(productId, quantity));

    uow.Commit();                            // (3) 保存
}                                            // (4) 破棄

在庫不足の検証や例外処理は省き、複数の変更を1回の確定へまとめる点だけに絞っています。Create()が引数なしなのも同じ理由で、必要な設定はFactoryが内部に持っている想定です。

なお、Factoryから作ること自体は必須条件ではありません。ここでは寿命の始まりをコード上で見えるようにするためにそうしています。

ここからは、(2)蓄積を担うリポジトリと、(1)作成・(3)保存・(4)破棄を担うユニットオブワークに分けて整理します。

リポジトリは対象ごとの操作を受け持つ

(2)蓄積には、2つのリポジトリが並んでいます。商品リポジトリは商品の取得を、注文リポジトリは注文の追加を担当する、それぞれの対象への入口です。

ただしどちらも保存を確定していません。在庫を減らす変更も注文を追加する変更も、(3)保存を待っている状態です。

もし各リポジトリが個別に確定していたら、在庫は減ったのに注文の追加で失敗した、という中途半端な状態が起こり得ます。在庫を戻す後始末も、呼び出し側の仕事になります。

業務としての「注文する」は1つの処理です。だから確定も1回にまとめる——これがユニットオブワークの出発点です。

プロ美

対象ごとに入口を分けたのはいいけど、保存まで別々だと、かえって困っちゃうんだね。

ユニットオブワークが一つの作業境界を作る

ここまでみてきた一連の変更を追跡し、最後にまとめて永続化する働きは、DDD固有のものではありません。データベースを扱うライブラリで広く使われているパターンです。

(1)作成で作業単位が始まり、(4)破棄で終わり、その間に蓄えた変更が確定するのは(3)保存だけ。保存の出口が1つしかないことが、この境界の要点です。

DDDでは、これを集約への入口であるリポジトリと組み合わせて使います。

  • リポジトリ:どの集約を扱うか
  • ユニットオブワーク:いつまとめて保存するか

この整理は第22回でも触れました。ここからは「いつ保存するか」の側を、EF Coreでどう実現するのかをみていきます。

EF CoreではDbContextがユニットオブワークになる

この4段階をEF Coreで実装するにあたり、ゼロから作る必要はありませんDbContextが、ユニットオブワークに必要な機能をすでに持っているからです。

図2: 一般的なユニットオブワークとEF Coreの対応

4段階はそれぞれDbContextの使い方に対応します。(1)作成と(2)蓄積、(3)保存、(4)破棄の順にみていきましょう。

DbContextを一つ作り、変更を追跡する

(1)作成はDbContextを1つ作ること、(2)蓄積はその同じDbContextを通してエンティティを取得し、変更していくことです。1回の作業単位に対して1つが基本になります。

前回にも登場したとおり、DbContextは自分が取得したエンティティを覚えていて、取得時からの変更を追跡します。これを変更追跡(Change Tracking)と呼びます。

重要なのは追跡の単位がDbContextごとだという点です。複数のリポジトリの変更を1回で保存したいなら、それらが同じDbContextを使っている必要があることになります。

SaveChangesAsyncでまとめて保存する

(3)保存はSaveChangesAsyncです。同じDbContextに蓄えられた変更を、まとめてDBへ反映します。

呼び出しが1回であることが大事です。3つのエンティティを変更していても、1回のSaveChangesAsyncならDBへの反映も1回にまとまります。

逆に、そこへ到達する前に失敗すれば、追跡されていた変更は確定されないまま捨てられます。DBには何も反映されません。

補足:トランザクションとの関係

ユニットオブワークはアプリ側で変更をまとめる設計パターン、トランザクションはDB側の仕組みで、同じものではありません。

ただしEF Coreでは、対応するDBプロバイダーなら1回のSaveChangesAsyncが既定でトランザクションとして実行されるため、両者が重なります。

DbContextを破棄して作業単位を終える

(4)破棄では、使い終わったDbContextを破棄します。DbContext短命に使うことが前提の部品で、1回の作業単位が終わったら破棄し、次は新しく作り直します。

破棄すると変更追跡も解除されます。前の処理で読み込んだデータが次の処理へ持ち越され、古い内容のまま使われる、といったことが起きません。

では、そのDbContextは誰が作り、どこで破棄するのでしょうか。ここからはMentorAppの実装でみていきます。

MentorAppのユニットオブワーク実装をみる

DbContextには保存も破棄も備わっています。ですから、これをApplication層へそのまま渡しても動きはします。

ただしその瞬間、Application層のコードにDbContextというEF Coreの型が現れます。前回リポジトリでEF Coreを隠したのに、保存の入口だけ技術詳細が漏れ出します。

そこでMentorAppはDbContextを独自の契約(インターフェース)で包みます。

Application層に見せるのは(1)作成・(2)蓄積・(3)保存・(4)破棄という4段階の操作だけです。

プロ太

Application層から技術詳細(EF Core関連)をすべて切り離すということですね。

関係するファイルは次の場所にあります。

src/
├─ MentorApp.Domain/Models/                     … 契約(インターフェース)
│   ├─ Shared/IUnitOfWork.cs                    ← 今回
│   ├─ Shared/IUnitOfWorkFactory.cs             ← 今回
│   ├─ Users/IUserRepository.cs
│   ├─ Mentorships/IMentorshipRepository.cs
│   └─ Topics/ITopicRepository.cs
└─ MentorApp.Infrastructure/Persistence/        … EF Coreによる実装
    ├─ AppDbContext.cs
    ├─ DbUnitOfWork.cs                          ← 今回
    ├─ DbUnitOfWorkFactory.cs                   ← 今回
    └─ Repositories/
        ├─ UserRepository.cs
        ├─ MentorshipRepository.cs
        └─ TopicRepository.cs

ユニットオブワークもリポジトリも、契約はDomain層、実装はInfrastructure層で、上下がそのまま対応しています。

ファイルを個別に開く前に、下側のInfrastructure層でFactory・ユニットオブワーク・リポジトリ・DbContextがどうつながっているかを図でみておきます。

図3: MentorAppのユニットオブワーク実装

図3の(1)作成〜(4)破棄は、前半の4段階と同じです。

まずユニットオブワークに関する2つのインターフェースが何を約束しているかを確認し、続いて(1)作成と(2)蓄積、(3)保存と(4)破棄の順に実装を読みます。

二つのインターフェースで一連の操作を抽象化する

Application層から見える契約はIUnitOfWorkFactoryIUnitOfWorkの2つで、定義は第22回で紹介したものです。

// IUnitOfWorkFactory.cs(Domain層)
public interface IUnitOfWorkFactory
{
    public Task<IUnitOfWork> CreateAsync(CancellationToken cancellationToken = default);
}

// IUnitOfWork.cs(Domain層)
public interface IUnitOfWork : IAsyncDisposable
{
    public IUserRepository Users { get; }

    public IMentorshipRepository Mentorships { get; }

    public ITopicRepository Topics { get; }

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

IUnitOfWorkFactoryが公開しているのはCreateAsyncだけ、つまり図3の(1)作成の入口です。

IUnitOfWorkは残りの3段階です。3つのリポジトリを取得して(2)蓄積SaveChangesAsync(3)保存します。

IAsyncDisposableを継承しているので(4)破棄もできます。

どちらにもDbContextDbSetが出てこない点に注目してください。Application層は、EF Coreの型を一切知らずに済みます。

Factoryで作り、三つのリポジトリで共有する

実装の1つ目は、「(1)作成」を担当するDbUnitOfWorkFactoryです。全文でもこれだけです。

// DbUnitOfWorkFactory.cs(Infrastructure層)
internal sealed class DbUnitOfWorkFactory(IDbContextFactory<AppDbContext> dbContextFactory) : IUnitOfWorkFactory
{
    public async Task<IUnitOfWork> CreateAsync(CancellationToken cancellationToken = default)
    {
        var dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
        return new DbUnitOfWork(dbContext);
    }
}

やっていることは新しい作業単位を1つ用意する、それだけです。まだ何も追跡していないAppDbContextを作り、DbUnitOfWorkで包んで返しています。

Blazor ServerでFactoryを使う理由

Blazor Serverでは、ユーザーが画面を開いている間、サーバー側で同じアプリとその部品が動き続けます

何もしないとDbContextまで一緒に長生きしてしまうため、MentorAppではユースケースのたびにFactoryで新しく作り、使い終わったら破棄します。

DIコンテナへはAddDbContextFactory<AppDbContext>()で登録します(PersistenceExtensions)。

この登録ではAppDbContext自体はDIに入らないため、DbContextはFactory経由でしか手に入りません

続いて、「(2)蓄積」を支えるDbUnitOfWorkです。

// DbUnitOfWork.cs(抜粋)
internal sealed class DbUnitOfWork(AppDbContext dbContext) : IUnitOfWork
{
    private UserRepository? _users;
    private MentorshipRepository? _mentorships;
    private TopicRepository? _topics;

    public IUserRepository Users => _users ??= new UserRepository(dbContext);

    public IMentorshipRepository Mentorships => _mentorships ??= new MentorshipRepository(dbContext);

    public ITopicRepository Topics => _topics ??= new TopicRepository(dbContext);
    // ...
}

見るべきは1点だけです。3つのプロパティが、リポジトリへ同じdbContextを渡しています。図3の(2)蓄積で3本の線が1つのAppDbContextへ集まっていたのは、このことです。

これで3つのリポジトリによる変更が、1つの変更追跡にまとまります

なお??=は、まだ値がなければ生成して代入する演算子です。

プロ美

同じdbContextを渡す」だけなんだ!これで複数リポジトリの変更をまとめられるんだね。

保存し、破棄して作業単位を終える

残るは「(3)保存」「(4)破棄」です。同じDbUnitOfWorkの続きの部分をみます。

// DbUnitOfWork.cs(抜粋・続き)
internal sealed class DbUnitOfWork(AppDbContext dbContext) : IUnitOfWork
{
    // ...(3つのリポジトリのプロパティ)

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

    public ValueTask DisposeAsync()
        => dbContext.DisposeAsync();
}

どちらもdbContextへそのまま渡すだけの、非常に薄い実装です。

(3)保存は3つのリポジトリが同じDbContextへ蓄えた変更を1回の呼び出しでまとめてDBへ反映し、(4)破棄はそのDbContextを破棄し、作業単位を終えます。

この2つを呼ぶのはApplication層のサービスです。ここでは例として、MentorshipServiceのメンタリング作成メソッドをみます。

// MentorshipService.cs(Application層・抜粋。例外処理とログは省略)
public class MentorshipService(IUnitOfWorkFactory unitOfWorkFactory, /* ... */)
{
    public async Task<Mentorship> CreateMentorshipAsync(
        CreateMentorshipRequest request,
        CurrentUser currentUser,
        CancellationToken cancellationToken = default)
    {
        // (1) 作成:ユニットオブワークを作る
        await using var uow = await unitOfWorkFactory.CreateAsync(cancellationToken);

        // (2) 蓄積:リポジトリで取得・追加し、同じDbContextへ変更を蓄える
        var mentor = await uow.Users.FindByIdAsync(request.MentorUserId, cancellationToken)
            ?? throw new ArgumentException(...);
        // ...(業務ルールの確認)
        await uow.Mentorships.AddAsync(mentorship, cancellationToken);

        // (3) 保存:蓄えた変更をまとめて反映する
        await uow.SaveChangesAsync(cancellationToken);

        return mentorship;
    } // (4) 破棄:このメソッドを抜けるとき、uow の DisposeAsync が呼ばれる
}

(3)保存は明示的に呼びますが、(4)破棄は書かれていません。引き受けているのはawait usingで、uowを宣言したメソッドを抜けるときDisposeAsyncを自動的に呼びます。

途中で例外が発生した場合も呼ばれます。業務ルール違反で例外を投げてもDbContextは必ず破棄され、SaveChangesAsyncには到達していないのでDBには何も反映されません

プロ太

前半の注文処理で見た(1)作成〜(4)破棄が、そのままユースケース1つ分のメソッドに収まっていますね。

ちなみにこのコード、例外処理とログを省いています。実物はもう少し賑やかで、その全体が次回の主役です。

まとめ

今回は、複数のリポジトリによる変更を1回で確定させるユニットオブワークを、EF Coreで実装しました。重要なポイントは以下です。

  • ユニットオブワークはDDD固有ではない。一連の変更をまとめて確定する一般的なパターン
  • リポジトリは集約への入口、ユニットオブワークは保存を確定する境界
  • EF CoreではDbContextがその役割を持つ。(1)作成 →(2)蓄積 →(3)保存 →(4)破棄
  • 複数のリポジトリが同じDbContextを共有する。だから1回のSaveChangesAsyncで変更をまとめられる
  • Factoryから短命なDbContextを作り、await usingで確実に破棄する

次回はApplication層のユースケースそのものを主役にします。

今回省いた例外処理やログ、それに認可も加えて、1つのユースケースに何を書き、何を書かないのかを整理します。

プロ太

実装そのものは驚くほど薄いのに、「保存の出口を1つにする」という設計がきちんと形になっていましたね。

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

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