Last updated: September 29, 2026 · Tested with .NET 10 (SDK 10.0.201) and EF Core 10.0.12 on SQLite
Short answer: the Unit of Work pattern groups several changes into one commit so they all succeed or all fail, and the Repository pattern hides how entities are loaded and saved behind an interface. In EF Core you already have both: DbContext is the unit of work, DbSet<T> is the repository, and one SaveChangesAsync() call is one database transaction. Wrap them in your own IUnitOfWork and IRepository<T> only when you get something for it: named queries in one place, a business layer that does not reference EF Core, or simpler unit tests. Below is a working implementation on .NET 10, with the real output for commits, rollbacks and the one mistake that silently breaks atomicity.
What the Two Patterns Do
A repository behaves like an in-memory collection of domain objects. Instead of writing _context.Customers.Where(c => c.IsActive).ToList() in ten places, you call customers.GetActiveAsync() and the query lives in one class.
A unit of work tracks everything you change during one business operation and writes it out in a single atomic step. Placing an order means inserting the order and reducing stock. If the second write fails after the first succeeded, your data is wrong. The unit of work makes them one commit.
Note what is missing from the repository interface below: there is no Save method. Saving belongs to the unit of work, not to an individual repository. That separation is the whole point.
The Implementation
public interface IRepository<T> where T : class
{
Task<T?> GetByIdAsync(int id, CancellationToken ct = default);
Task<IReadOnlyList<T>> FindAsync(Expression<Func<T, bool>> predicate, CancellationToken ct = default);
Task AddAsync(T entity, CancellationToken ct = default);
void Update(T entity);
void Remove(T entity);
}
public class Repository<T>(AppDbContext context) : IRepository<T> where T : class
{
private readonly DbSet<T> _set = context.Set<T>();
public async Task<T?> GetByIdAsync(int id, CancellationToken ct = default)
=> await _set.FindAsync([id], ct);
public async Task<IReadOnlyList<T>> FindAsync(Expression<Func<T, bool>> predicate, CancellationToken ct = default)
=> await _set.Where(predicate).ToListAsync(ct);
public async Task AddAsync(T entity, CancellationToken ct = default)
=> await _set.AddAsync(entity, ct);
public void Update(T entity) => _set.Update(entity);
public void Remove(T entity) => _set.Remove(entity);
}
public interface IUnitOfWork
{
IRepository<Customer> Customers { get; }
IRepository<Order> Orders { get; }
IRepository<Product> Products { get; }
Task<int> CompleteAsync(CancellationToken ct = default);
}
public class UnitOfWork(AppDbContext context) : IUnitOfWork
{
public IRepository<Customer> Customers { get; } = new Repository<Customer>(context);
public IRepository<Order> Orders { get; } = new Repository<Order>(context);
public IRepository<Product> Products { get; } = new Repository<Product>(context);
public Task<int> CompleteAsync(CancellationToken ct = default) => context.SaveChangesAsync(ct);
}
Two deliberate differences from the version you see in most tutorials. The methods return IReadOnlyList<T>, not IEnumerable<T> or IQueryable<T>, so callers cannot accidentally re-run or extend the query. And UnitOfWork does not implement IDisposable: the DI container created the DbContext, so the container disposes it. A unit of work that disposes a context it does not own causes ObjectDisposedException in whatever else shares that scope.
Register both as scoped, so each request gets one context shared by all repositories:
builder.Services.AddDbContext<AppDbContext>(o =>
o.UseSqlServer(builder.Configuration.GetConnectionString("Default")));
builder.Services.AddScoped<IUnitOfWork, UnitOfWork>();
And the service that uses it:
public class OrderService(IUnitOfWork uow)
{
public async Task PlaceOrderAsync(int customerId, int productId, int qty, CancellationToken ct = default)
{
var product = await uow.Products.GetByIdAsync(productId, ct);
if (product is null || product.Stock < qty)
throw new InvalidOperationException("Insufficient stock.");
product.Stock -= qty; // tracked entity: no Update() call needed
await uow.Orders.AddAsync(new Order { CustomerId = customerId, ProductId = productId, Quantity = qty }, ct);
await uow.CompleteAsync(ct); // one SaveChanges = one transaction
}
}
What Actually Happens: Seven Runs Against a Real Database
I ran the code against SQLite with foreign keys enabled, starting with one product (stock 5) and no orders. After every step the program re-reads the state from a fresh context, so what you see is what is in the database, not what is in memory.
1) Unit of Work: stock update + order insert commit together
state after a successful order: stock=3, orders=1
2) A failure before CompleteAsync writes nothing
threw: Insufficient stock.
state after a rejected order: stock=3, orders=1
3) A database error inside ONE SaveChanges rolls back everything in it
threw: SQLite Error 19: 'FOREIGN KEY constraint failed'.
state after a failed SaveChanges: stock=3, orders=1
4) The trap: two SaveChanges calls are two transactions
second SaveChanges failed, first one is already committed
state after two separate SaveChanges: stock=2, orders=1
5) The fix when you really need two saves: an explicit transaction
rolled back both saves
state after an explicit transaction: stock=2, orders=1
6) Generic Update() vs change tracking: the SQL EF Core sends
tracked change : UPDATE "Products" SET "Stock" = @p0 WHERE "Id" = @p1
repository.Update: UPDATE "Products" SET "Name" = @p0, "Price" = @p1, "Stock" = @p2 WHERE "Id" = @p3
7) Same order without the wrappers: DbContext already is a unit of work
state after plain DbContext: stock=0, orders=2
Runs 1 to 3: atomicity comes from SaveChanges, not from your wrapper
In run 3 the stock change was valid and the order insert violated a foreign key. Both were part of one CompleteAsync(), so EF Core wrapped them in one transaction and the database rolled back both: stock stayed at 3. You did not write any transaction code for that. It is built into SaveChanges.
Run 4: the mistake that breaks it
Call SaveChanges twice and you have two transactions. The stock went from 3 to 2 and stayed there even though the order that justified it failed. This is the most common way a "unit of work" stops being one: a repository method that saves internally, a helper that calls SaveChangesAsync "to get the generated Id", or a generic AddAndSaveAsync convenience method. If any repository can save on its own, you no longer have a unit of work.
Run 5: when you genuinely need two saves
Sometimes you do need an intermediate save, for example to get a database-generated key before building the next row. Then open the transaction yourself:
await using var tx = await db.Database.BeginTransactionAsync();
try
{
product.Stock -= 1;
await db.SaveChangesAsync();
db.Orders.Add(order);
await db.SaveChangesAsync();
await tx.CommitAsync();
}
catch (DbUpdateException)
{
await tx.RollbackAsync();
}
Both saves rolled back and the stock stayed at 2. If you use a retrying execution strategy (EnableRetryOnFailure on SQL Server or Azure SQL), wrap the whole block in db.Database.CreateExecutionStrategy().ExecuteAsync(...), otherwise EF Core throws because it cannot retry a transaction you started by hand.
Run 6: what a generic Update() costs
When you load an entity and change a property, EF Core's change tracker knows exactly what changed and sends SET "Stock" = @p0. When you call repository.Update(entity) on an object EF Core did not load, it has nothing to compare against, marks every property as modified, and sends all columns. Two consequences: a concurrent edit to Price by someone else is overwritten with your stale value, and wide tables pay for writing every column. Inside a unit of work you almost never need Update(). Load, modify, complete.
Run 7: the same thing without the pattern
Four lines with a plain DbContext produced the same atomic result. That is the honest baseline to compare your abstraction against.
Should You Use Repository and Unit of Work With EF Core?
| Use the wrappers when | Skip them when |
|---|---|
| The same queries are repeated across many services and you want them named and in one place | The app is small or mostly CRUD, and the wrapper would only forward calls |
| Your domain or application layer must not reference EF Core (clean or hexagonal architecture) | You rely on EF features such as Include, projections, split queries or compiled queries in most calls |
| You want service tests that run with simple fakes and no database | You already test against SQLite or Testcontainers, which also verifies the SQL |
| You may replace the data store for part of the model, for example Dapper for reads | You organise by feature (vertical slices, CQRS handlers) and each handler owns its query |
If you do wrap, prefer specific repositories with intention-revealing methods (GetActiveWithOpenOrdersAsync) over a purely generic one. The generic repository is a fine base class and a poor public API, because every interesting query ends up needing something it does not expose. See the repository pattern guide for that side in detail, and CQRS with MediatR or vertical slice architecture for the alternative where handlers talk to the DbContext directly.
Gotchas I Hit While Testing
- EF Core turns on SQLite foreign keys for you. SQLite ignores foreign keys by default, so I expected to need
PRAGMA foreign_keys = ON;on the connection I opened myself. I ran step 3 with and without it: both rejected the order withSQLite Error 19. The pragma only matters for code that talks to the connection without EF Core, such as Dapper or a rawSqliteCommand. - The EF Core in-memory provider is not a database. It has no transactions and no constraints, so runs 3 to 5 cannot be reproduced with it. Use SQLite in-memory (
Data Source=:memory:with one open connection) for tests that need real behaviour. - Do not expose
IQueryable. It lets the caller addWhereandIncludeafter the fact, which moves query logic back into the business layer and turns a disposed context into a runtime exception far from the cause. AddAsyncis rarely needed. It exists for value generators that hit the database, such as HiLo sequences. For ordinary keysAddis synchronous work and either is fine.- One context per unit of work, never used in parallel.
DbContextis not thread-safe. When EF Core detects overlapping calls it throws "A second operation was started on this context instance before a previous operation completed", but detection is not guaranteed. I ran three repository queries withTask.WhenAllon one unit of work against SQLite and got no exception at all, which is the dangerous case: it works on your machine and fails under load on a real server. Await repository calls one at a time, or give each parallel branch its own scope. - Background jobs need their own scope. A scoped unit of work injected into a singleton or a hosted service fails at startup or, worse, lives forever. Create a scope per job with
IServiceScopeFactory.
Testing a Service That Uses IUnitOfWork
The payoff of the abstraction is a test with no EF Core in it. A hand-written fake is enough:
public class FakeUnitOfWork : IUnitOfWork
{
public int Completed { get; private set; }
public IRepository<Customer> Customers { get; } = new FakeRepository<Customer>();
public IRepository<Order> Orders { get; } = new FakeRepository<Order>();
public IRepository<Product> Products { get; } = new FakeRepository<Product>();
public Task<int> CompleteAsync(CancellationToken ct = default) { Completed++; return Task.FromResult(1); }
}
[Fact]
public async Task Rejects_order_when_stock_is_too_low_and_saves_nothing()
{
var uow = new FakeUnitOfWork();
await uow.Products.AddAsync(new Product { Id = 1, Name = "Keyboard", Stock = 1 });
await Assert.ThrowsAsync<InvalidOperationException>(
() => new OrderService(uow).PlaceOrderAsync(1, 1, qty: 5));
Assert.Equal(0, uow.Completed);
}
The fake repository behind it is a List<T> with the same five methods. This test and a second one for the success path both pass under dotnet test in 79 ms, with no database involved.
That test checks your business rule. It does not check that the SQL works, that constraints hold, or that the save is atomic. Keep a smaller set of integration tests on a real provider for those, exactly like the seven runs above.
FAQ
Is DbContext a unit of work? Yes. It tracks changes and commits them in one transaction when you call SaveChanges. DbSet<T> is its repository.
Does SaveChanges use a transaction? Yes. If the provider supports transactions, all changes in one SaveChanges call are applied in one transaction and rolled back together on failure, as run 3 shows.
Where should SaveChanges be called? Once, at the end of the business operation, from the application service or handler. Never inside a repository method.
Should the unit of work be scoped, transient or singleton? Scoped, the same lifetime as the DbContext it wraps, so every repository in a request shares one context.
Do I need the repository pattern with EF Core at all? Not for correctness. Use it for organisation and testability when those benefits are real in your codebase, and skip it for small or query-heavy applications.
How do I span two DbContexts or a database and a message broker? Not with this pattern. Use the transactional outbox, as shown in the .NET microservices guide. For two contexts on the same database, share one connection and one transaction with UseTransaction.
Conclusion
The Unit of Work pattern is one rule: one business operation, one commit. EF Core gives you that for free as long as you call SaveChanges once, and takes it away the moment something saves in the middle. Build the repository and unit-of-work wrappers when they buy you named queries, a persistence-free business layer or fast tests, keep saving out of the repositories, avoid the generic Update() on tracked entities, and test atomicity against a real database rather than assuming it. If you are structuring the rest of the application around this, clean architecture in C# shows where these interfaces belong.
References: EF Core: Using transactions · EF Core: Choosing a testing strategy · DbContext lifetime and configuration
Comments
Post a Comment