【C#/Blazor】実務Webアプリ開発編 (29)ユニットオブワークをEF Coreで実装する ~複数リポジトリの変更を1回の保存で確定する~
実務Webアプリ開発編です。今回のテーマは、DDD(ドメイン駆動設計)において、ユニットオブワーク(Unit of Work)をEF Coreで実装する方法です。
シリーズとしては、前回でリポジトリの実装が完成しました。今回は、複数のリポジトリによる変更をいつ・どこでまとめて確定するかを担う部品をつくります。
以下のような方に役立つ内容となっています。
- ユニットオブワークという言葉は聞くが、何をする部品なのかピンとこない
- EF Coreの
DbContextとユニットオブワークの関係を整理したい - 複数のリポジトリにまたがる変更を、どうまとめて保存するのか知りたい
- Blazor Serverで
DbContextの寿命をどう扱えばよいのか知りたい
以下のようなMentorAppを題材として進めます。

GitHubにドキュメント・コードの一式があります。
リポジトリのインターフェース定義と、その実装が土台になります。以下の記事とつなげて読むと理解しやすいです。
前回のコードに出てきたuow——「詳しくは次回」と言っていた、あの部品です。今回はその中身を作ります。
なぜリポジトリだけでは保存が完結しないのか
前回みたとおり、リポジトリは集約を取得し、変更を登録するところまでを担当しました。DBへ実際に書き込むのは、リポジトリの仕事ではありませんでしたね。
それを引き受けるのがユニットオブワーク(Unit of Work)です。前回のコードにもuowという変数名で少しだけ登場していました。
名前のとおり「作業単位」を表す部品です。ユースケース1回ぶんがその単位で、その中で起きた変更を覚えておき、最後にまとめて保存を確定します。
MentorAppとEF Coreはいったん離れ、「商品を注文する」という題材でイメージをつかみましょう。注文では在庫を1つ減らし、注文を1件追加します。扱う対象が2つあるので、リポジトリも2つ登場します。

図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が、ユニットオブワークに必要な機能をすでに持っているからです。

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の(1)作成〜(4)破棄は、前半の4段階と同じです。
まずユニットオブワークに関する2つのインターフェースが何を約束しているかを確認し、続いて(1)作成と(2)蓄積、(3)保存と(4)破棄の順に実装を読みます。
二つのインターフェースで一連の操作を抽象化する
Application層から見える契約はIUnitOfWorkFactoryとIUnitOfWorkの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)破棄もできます。
どちらにもDbContextやDbSetが出てこない点に注目してください。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アプリの開発について一緒に学んでいきましょう!





