The second half of the same story lesson 247 started — and the moment where an honest lesson has to tell you that the thing you're about to build might already exist in your codebase.
Picture a funds-transfer operation: debit $200 from Account A, credit $200 to Account B. If each side is handled by its own repository, each saving its own changes independently, look at what can go wrong: the debit repository saves successfully, the process crashes or an exception is thrown, and the credit repository's save never happens. $200 has vanished — not transferred, just gone, because two operations that needed to succeed or fail together were allowed to succeed or fail independently.
This is the exact problem lesson 247 left dangling at the end: "a single business operation often needs to touch several repositories together, atomically." Unit of Work is the pattern that closes that gap — and, as you're about to see, it's also the pattern with the most genuinely surprising twist in this whole Part: for most EF Core applications, you may have been using an implementation of it since Intermediate Part VI, without a separate interface in sight.
In this lesson, you'll learn what Unit of Work solves, how DbContext already gives you one, and the specific, narrower situations where hand-rolling a separate IUnitOfWork abstraction on top of it genuinely earns its cost.
A Unit of Work tracks every change made across a business operation — additions, updates, deletions, possibly spanning several different repositories — and commits all of them together, in one atomic step, at the end. Either every change succeeds, or none of them are permanently applied. It's the mechanism that makes "debit Account A, credit Account B" behave as one indivisible operation instead of two separately-saved ones.
Martin Fowler's original definition: a Unit of Work "maintains a list of objects affected by a business transaction and coordinates the writing out of changes." Two responsibilities are bundled into that one sentence — tracking (remembering what changed, across however many objects and repositories were touched) and committing (writing all of it out together, as one atomic operation). Both of those responsibilities should sound immediately familiar if you've worked with EF Core at all — because they are, precisely, what DbContext already does.
Go back to Intermediate's DbContext lesson (141): a DbContext was described as a connection factory, a change tracker, and a query gateway — and lesson 148 traced that change tracker directly back to the textbook definition of Unit of Work. That wasn't a passing comparison; it's the load-bearing fact this entire lesson is built on.
This is the most important, and most honest, point of this lesson: a single DbContext instance, used across several repositories within one scope, already behaves as their shared Unit of Work. If AccountRepository and TransferLogRepository both receive the same DbContext instance (which is exactly what scoped DI lifetime, from the Dependency Injection lessons, gives you by default in ASP.NET Core), then modifying an Account through one repository and adding a TransferLog through the other are both being tracked by that one shared change tracker — and one call to context.SaveChanges() commits both, together, in one transaction. You do not need to build anything new to get that.
public interface IAccountRepository
{
Task<Account?> GetByIdAsync(int id);
}
public class AccountRepository(BankDbContext context) : IAccountRepository
{
public Task<Account?> GetByIdAsync(int id) => context.Accounts.FindAsync(id).AsTask();
}
public interface ITransferLogRepository
{
Task AddAsync(TransferLog log);
}
public class TransferLogRepository(BankDbContext context) : ITransferLogRepository
{
public Task AddAsync(TransferLog log) => context.TransferLogs.AddAsync(log).AsTask();
}
// ── Application service — orchestrates BOTH repositories AND commits once ──
public class TransferService(
IAccountRepository accounts,
ITransferLogRepository logs,
BankDbContext context) // the same DbContext the repositories above were built with
{
public async Task TransferAsync(int fromId, int toId, decimal amount)
{
var from = await accounts.GetByIdAsync(fromId) ?? throw new InvalidOperationException("Account not found.");
var to = await accounts.GetByIdAsync(toId) ?? throw new InvalidOperationException("Account not found.");
from.Balance -= amount;
to.Balance += amount;
await logs.AddAsync(new TransferLog(fromId, toId, amount, DateTime.UtcNow));
// ONE call commits the balance change on BOTH accounts AND the new log — together
await context.SaveChangesAsync();
}
}Code → Meaning → Result: TransferService touches two repositories and modifies three things (two account balances, one new log entry) — and calls SaveChanges() exactly once, because context is the same instance both repositories were given. If the database rejects the save for any reason, EF Core rolls back every one of those changes together — nothing is left half-applied. No IUnitOfWork interface was written, and none was needed: DbContext was already doing this job.
Now change the scenario, the same way lesson 148 did: this order-fulfillment system must coordinate a change in its EF Core-backed order database and a change in a separate legacy inventory system reached through a different data-access technology entirely — a Dapper-based repository with no shared change tracker, no shared DbContext, and no built-in way to participate in the same atomic commit. Here, EF Core's free Unit of Work simply doesn't reach — it only ever coordinated changes made through its own tracked entities.
// ── A genuine hand-rolled Unit of Work — because two DIFFERENT data technologies are involved ──
public interface IUnitOfWork : IAsyncDisposable
{
IOrderRepository Orders { get; }
IInventoryRepository Inventory { get; } // Dapper-backed, NOT EF Core
Task CommitAsync();
}
public class SqlUnitOfWork : IUnitOfWork
{
private readonly BankDbContext _context;
private readonly IDbTransaction _sharedTransaction; // shared across BOTH technologies
public SqlUnitOfWork(BankDbContext context, IDbConnection connection)
{
_context = context;
_sharedTransaction = connection.BeginTransaction();
_context.Database.UseTransaction((System.Data.Common.DbTransaction)_sharedTransaction);
Orders = new EfOrderRepository(context);
Inventory = new DapperInventoryRepository(connection, _sharedTransaction);
}
public IOrderRepository Orders { get; }
public IInventoryRepository Inventory { get; }
public async Task CommitAsync()
{
await _context.SaveChangesAsync(); // EF Core's changes
_sharedTransaction.Commit(); // Dapper's changes, same underlying transaction
}
public ValueTask DisposeAsync()
{
_sharedTransaction.Dispose();
return _context.DisposeAsync();
}
}This is a legitimate, hand-rolled Unit of Work — and notice why it exists: not "because Unit of Work is a pattern you should always implement," but because two genuinely different persistence technologies needed to be coordinated under one shared database transaction, something EF Core's own change tracker has no way to do on its own. The other legitimate reason, mentioned but not shown in code here: a team that specifically wants its application layer to depend on IUnitOfWork rather than on DbContext directly, as a deliberate technology-independence boundary — the same kind of reasoning lesson 148 covered for repositories generally.
Imagine buying five items at a store, and the cashier rings each one up as a completely separate transaction — five separate charges to your card, five separate receipts. If your card gets declined on item four, you've already paid for three items and gotten nothing for the fourth or fifth — an inconsistent, half-finished purchase. A Unit of Work is the cashier ringing up all five items as one transaction: one card swipe, one receipt, one point where everything either goes through together or the whole sale is cancelled and nothing is charged.
Now here's the twist this lesson is built around: if you've ever checked out at a modern store with everything scanned into one basket before a single payment, you already had this — you didn't need to ask for it specially. DbContext is that basket. It's only when you're paying with two completely different payment systems at once — a card and a separate loyalty-points ledger that doesn't know about the card system — that you'd need someone to specifically coordinate both of them into a single, all-or-nothing checkout.
The Unit of Work pattern is the behavior: track changes across an operation, commit them together. An IUnitOfWork C# interface is one possible implementation vehicle for that behavior — and, as this lesson has shown, EF Core's DbContext is another, already built and already in your project. Confusing "the pattern" with "a dedicated interface named after the pattern" is exactly why teams sometimes build a redundant IUnitOfWork wrapper around a DbContext that was already fulfilling the role.
You will see "Repository and Unit of Work pattern" used together constantly in .NET articles and tutorials — often followed by two full custom abstractions. Given everything in this lesson, read that phrase more precisely: it usually just means "repositories that share one DbContext instance, given to them via DI, with the application service calling SaveChanges() once at the end." That's Repository plus Unit of Work — the second half just doesn't need its own interface most of the time.
Writing IUnitOfWork with an IAccountRepository Accounts { get; }, an ITransferLogRepository TransferLogs { get; }, and a CommitAsync() that just calls _context.SaveChangesAsync() — an entire extra interface and class whose only job is to forward to the DbContext it wraps, with no second technology, no independence-from-EF-Core requirement, nothing.
Inject the shared DbContext directly into the application service and call SaveChangesAsync() on it, exactly as the Simple Example did. The wrapper adds a file and an indirection without adding a genuine new capability.
A repository registered with a transient lifetime, or one that constructs new BankDbContext(options) itself instead of receiving the DI-provided instance — silently breaking the "shared change tracker" mechanism that made atomic commits free in the first place.
Register DbContext as scoped (ASP.NET Core's EF Core template does this by default) and let every repository receive it via constructor injection. This one lifetime decision is what makes the whole "DbContext is already a Unit of Work" story true in practice.
AccountRepository.Debit() calling context.SaveChangesAsync() itself, and TransferLogRepository.AddAsync() also calling it — reintroducing the exact "each piece commits independently" problem this lesson opened with, even though both repositories share one DbContext.
Repository methods should modify the tracked entities and stop; the application service orchestrating the whole operation calls SaveChangesAsync() exactly once, after every repository involved has made its changes — this is what actually makes the commit atomic.
| Situation | Leans toward |
|---|---|
| All repositories involved are EF Core-backed, sharing one scoped DbContext | Nothing extra to build — call SaveChangesAsync() once, in the application service |
| An operation must coordinate EF Core and a different data-access technology (Dapper, a different database, a queue) under one atomic commit | A genuine, hand-rolled Unit of Work coordinating a shared transaction across both |
| A deliberate architectural rule that the application layer must not depend on DbContext as a concrete type | An IUnitOfWork interface as the technology-independence boundary — same reasoning as lesson 148's architectural-boundary case for repositories |
| "Repository and Unit of Work always go together, so I should build both" as the only justification | Skip the separate Unit of Work — a shared DbContext is already doing that job |
You've seen why an operation touching multiple repositories needs to commit atomically, and the honest fact that DbContext usually already provides that. Let's confirm the reasoning holds up under new scenarios.
1. A team has AccountRepository and TransferLogRepository, both registered as scoped services that receive the same scoped BankDbContext via constructor injection. An application service calls methods on both, then calls context.SaveChangesAsync() once. Does this application already have a working Unit of Work?
Correct: B
Why B is correct: This is the lesson's central point — the Unit of Work pattern is defined by its behavior (track changes, commit together), and a shared, scoped DbContext plus one final SaveChangesAsync() call delivers exactly that behavior, with no separate interface required.
Why A is incorrect: A dedicated interface is one possible implementation vehicle, not a requirement — the pattern is about the behavior, which this scenario already has.
Why C is incorrect: EF Core's DbContext is explicitly identified as a Unit of Work implementation in this lesson — the pattern applies to it directly, not only to non-EF-Core technologies.
Why D is incorrect: This describes the exact anti-pattern from Common Mistake 3 — repositories calling SaveChangesAsync() individually reintroduces the "partial commit" problem the pattern exists to prevent.
Reinforcement: Judge whether Unit of Work exists by its behavior — shared tracking, single atomic commit — not by whether a specifically-named interface is present.
2. A repository is accidentally registered with a transient DI lifetime and constructs its own new BankDbContext(options) instead of receiving the request-scoped instance other repositories share. What breaks?
Correct: B
Why B is correct: This is Common Mistake 2 — the "free Unit of Work" only works because repositories share one DbContext instance and therefore one change tracker. A separately-constructed DbContext has its own independent tracker; its changes are invisible to a SaveChangesAsync() call made on a different instance.
Why A is incorrect: EF Core performs no such automatic merging — each DbContext instance's change tracker is entirely independent of any other instance's, even against the same physical database.
Why C is incorrect: This is a runtime behavioral bug, not a compile-time error — the code compiles and runs, it just silently fails to commit atomically as intended.
Why D is incorrect: Nothing in EF Core or ASP.NET Core performs such automatic promotion — this is a real bug the developer would need to notice and fix by correcting the DI lifetime.
Reinforcement: The "shared DbContext = free Unit of Work" mechanism depends entirely on genuinely sharing one instance — scoped lifetime and DI-provided construction are load-bearing, not incidental.
3. Which of these is the scenario this lesson identifies as a genuine, well-justified reason to hand-roll a separate IUnitOfWork abstraction on top of EF Core?
Correct: B
Why B is correct: This is exactly the real-world example's scenario — EF Core's built-in Unit of Work behavior only reaches entities it tracks itself; coordinating an atomic commit across EF Core and a genuinely different technology requires an explicit, shared transaction that a hand-rolled Unit of Work can provide.
Why A is incorrect: Repository count alone isn't a deciding factor in this lesson's reasoning — many repositories sharing one DbContext are still covered by that one context's built-in Unit of Work behavior.
Why C is incorrect: This is explicitly the weak, rejected justification from Common Mistake 1 — building an abstraction "because the pattern says to," with no genuine capability gained.
Why D is incorrect: The choice of record vs. a plain class for entity types has no bearing on Unit of Work or transaction coordination at all.
Reinforcement: The genuine justification is a real technical gap — a second data-access technology or a real, independent decoupling requirement — not habit or convention alone.
Repository and Unit of Work, together, give you the full, honest picture of data-access architecture: how to fetch and persist well, and how to make multi-step operations atomic — most of which EF Core already hands you, if you recognize it. Next: Clean Architecture, where these same ideas scale up to an entire application's structure.
dotnetmadeeasy.com — Learn C# and .NET, the right way.