Unit of Work with Entity Framework Core Done Right

Learn what the Unit of Work pattern is, why EF Core's DbContext already behaves like one, and when a thin abstraction helps. We walk through small, runnable C# examples that show single commit flows, explicit transactions, and resilient retries. You will see how to avoid partial saves, how to wire dependencies with clean lifetimes, and which pitfalls to skip when layering repositories and services.

When your app touches multiple tables in one business action, you want all the changes to land together or not at all. That is the heart of the Unit of Work pattern.

What problem are we solving

Imagine placing an order that needs a new customer row, a new order row, and a stock update. If you call SaveChanges multiple times and something fails halfway, you get the data equivalent of a mullet haircut. Business in the front, party in the back, and your DBA is not laughing.

Here is the risky shape. Notice the two separate commits.

async Task PlaceOrderAsync(SalesDbContext db)
{
db.Add(new Customer { Name = "Michael" });
await db.SaveChangesAsync();
db.Add(new Order { CustomerId = 1, Total = 42m });
await db.SaveChangesAsync();
throw new Exception("Chaos monkey triggered a fire drill");
}

If that exception fires, the customer is saved but the order is not. Yikes.

The surprise hero in EF Core

Good news. EF Core’s DbContext already acts like a Unit of Work. It tracks your entity changes and wraps SaveChanges in a transaction by default. That means if you stage all the changes, then call SaveChanges once, EF Core commits them atomically.

First, two tiny entities so the rest of the samples compile.

public class Customer
{
public int Id { get; set; }
public string Name { get; set; } = "";
}
public class Order
{
public int Id { get; set; }
public int CustomerId { get; set; }
public decimal Total { get; set; }
}

And a small DbContext.

public class SalesDbContext : DbContext
{
public SalesDbContext(DbContextOptions<SalesDbContext> o)
: base(o)
{ }
public DbSet<Customer> Customers => Set<Customer>();
public DbSet<Order> Orders => Set<Order>();
}

Now do the work as a single unit. One SaveChanges. One atomic commit.

async Task PlaceOrderAsync(SalesDbContext db)
{
db.Add(new Customer { Name = "Dwight" });
db.Add(new Order { CustomerId = 2, Total = 42m });
await db.SaveChangesAsync(); // one transaction
}

That is the simplest and most effective Unit of Work you can write with EF Core.

When a light Unit of Work interface helps

Sometimes you want a seam for testing or you want your application layer to depend on an abstract unit rather than EF Core directly. Keep it light. No ceremony. No ornate scrolls signed by ancient architects.

Define a minimal interface.

public interface IUnitOfWork
{
Task<int> SaveChangesAsync(CancellationToken ct = default);
}

Implement it by delegating to DbContext.

public sealed class EfUnitOfWork : IUnitOfWork
{
private readonly SalesDbContext _db;
public EfUnitOfWork(SalesDbContext db) => _db = db;
public Task<int> SaveChangesAsync(CancellationToken ct = default) => _db.SaveChangesAsync(ct);
}

If you like repositories for expressiveness, keep those thin too.

public interface IOrderRepository { void Add(Order order); }
public sealed class OrderRepository : IOrderRepository
{
private readonly SalesDbContext _db;
public OrderRepository(SalesDbContext db) => _db = db;
public void Add(Order order) => _db.Orders.Add(order);
}

Then coordinate in your service and call SaveChanges once.

public sealed class CheckoutService
{
private readonly IOrderRepository _orders;
private readonly IUnitOfWork _uow;
public CheckoutService(IOrderRepository o, IUnitOfWork u)
{
_orders = o;
_uow = u;
}
public Task PlaceAsync(int customerId, decimal total)
{
_orders.Add(new Order { CustomerId = customerId, Total = total });
return _uow.SaveChangesAsync();
}
}

The service composes intent, the repository builds queries, the unit coordinates the commit. Nobody is writing a novel in their layers.

Transactions on purpose

One SaveChanges is usually enough. But sometimes you need to control the boundary explicitly, maybe to group a read update sequence or to break up a long batch across steps.

Use BeginTransaction when you must coordinate multiple SaveChanges calls.

async Task TransferCreditsAsync(SalesDbContext db)
{
using var tx = await db.Database.BeginTransactionAsync();
var jim = await db.Customers.FindAsync(1);
var pam = await db.Customers.FindAsync(2);
db.Update(jim!);
db.Update(pam!);
await db.SaveChangesAsync();
await tx.CommitAsync();
}

If you are on SQL Server or Azure SQL and want resilience, combine a transaction with EF Core’s execution strategy. This gives you retries on transient errors.

async Task DoWithRetryAsync(SalesDbContext db, Func<Task> action)
{
var strategy = db.Database.CreateExecutionStrategy();
await strategy.ExecuteAsync(async () =>
{
using var tx = await db.Database.BeginTransactionAsync();
await action();
await db.SaveChangesAsync();
await tx.CommitAsync();
});
}

Lifetimes, pitfalls, and the one weird trick

  • Use one DbContext per web request. Register it as scoped. Share that scope across repositories and services so everything lands in one SaveChanges.
  • Save once per business operation. Multiple SaveChanges invites partial commits.
  • EF Core already is a repository and a unit of work. Add abstractions only when they buy you seams for tests, cross cutting commits, or clear boundaries.
  • Avoid static or long lived DbContext instances. They hold change tracking state and will surprise you like Creed in a Halloween costume.

Registering services keeps the scope aligned.

builder.Services.AddDbContext<SalesDbContext>(o => o.UseSqlServer(conn));
builder.Services.AddScoped<IUnitOfWork, EfUnitOfWork>();
builder.Services.AddScoped<IOrderRepository, OrderRepository>();
builder.Services.AddScoped<CheckoutService>();

FAQ speed round

  • Does EF Core wrap SaveChanges in a transaction
    • Yes. One SaveChanges call is one transaction by default. If you call it twice, you get two transactions.
  • Do I need a repository pattern with EF Core
    • Not always. DbSet already gives you aggregate operations. Add repositories only for intent and boundary clarity.
  • What about multiple databases
    • Consider the Outbox pattern or distributed transactions. Trying to share a single transaction across heterogeneous stores is fragile.

Wrap up

The Unit of Work pattern is not about fancy layers. It’s about making one business action commit as one database action. EF Core already gives you a solid foundation with change tracking and transactional SaveChanges. Use it directly for simple flows, add a slim interface when you need seams, and reach for explicit transactions and retries when the mission goes beyond the basics. Your data stays consistent, your code stays simple, and your inner accountant sleeps better than Kevin with a vat of chili.