patron-unit-of-work

Unit of Work: Coordinate Changes and Transactions

  • 5 min

A Unit of Work is an object that records changes and coordinates their writing as a unit. Multiple repositories can share it to commit modifications of a business operation together.

In the previous article, we saw how the Repository Pattern allowed us to treat the database as if it were an in-memory collection.

But in the real world, business operations rarely affect a single table or entity. Imagine we are processing an order in an online store. The “Place Order” operation involves:

Inserting the record into the Orders table.

Deducting quantity in the Inventory table.

Creating an Invoice.

What happens if we use isolated repositories?

The Problem: Inconsistent Data

If we handle repositories in isolation, each one could be saving its changes immediately when called.

// ❌ The danger of not using transactions
public void ProcessOrder(Order order)
{
    // 1. Save the order (Success)
    _orderRepository.Add(order); 

    // 2. Deduct stock (ERROR: Power outage, network failure, or exception)
    _inventoryRepository.DeductStock(order.Items);
    
    // 3. Generate invoice
    _invoiceRepository.Add(new Invoice(order));
}
Copied!

If step 2 fails, we have a serious problem: We have an order created, but we haven’t deducted the stock. Our inventory is lying. The data is inconsistent.

We need these three operations to be Atomic: either they all happen, or none of them happen.

Implementation in C#

Let’s implement the pattern manually. Note: if you use Entity Framework Core, DbContext already acts as the Unit of Work. Still, it’s useful to understand what it coordinates.

The Interface

public interface IUnitOfWork : IDisposable
{
    // Access to repositories
    IUserRepository Users { get; }
    IOrderRepository Orders { get; }

    // The red button: Save everything
    int Commit();
}
Copied!

The Implementation

Here’s the key. The Unit of Work creates the context (the database connection) and passes it to all repositories. This way, they all work on the same in-memory transaction.

public class UnitOfWork : IUnitOfWork
{
    private readonly MyDbContext _context;
    
    // Backing fields for repositories
    private IUserRepository _users;
    private IOrderRepository _orders;

    public UnitOfWork(MyDbContext context)
    {
        _context = context;
    }

    // Lazy Loading of repositories: Only created if requested
    public IUserRepository Users 
    {
        get 
        {
            return _users ??= new UserRepository(_context);
        }
    }

    public IOrderRepository Orders 
    {
        get 
        {
            return _orders ??= new OrderRepository(_context);
        }
    }

    public int Commit()
    {
        // This is where the database is actually impacted
        return _context.SaveChanges();
    }

    public void Dispose()
    {
        _context.Dispose();
    }
}
Copied!

The Adapted Repositories

Repositories no longer create their own connection; they receive it.

public class OrderRepository : IOrderRepository
{
    private readonly MyDbContext _context;

    public OrderRepository(MyDbContext context)
    {
        _context = context;
    }

    public void Add(Order order)
    {
        // We only add to the in-memory context, we do NOT save yet
        _context.Orders.Add(order);
    }
}
Copied!

Usage in the Service (Transactional)

Now our business logic is safe.

public class OrderProcessor
{
    private readonly IUnitOfWork _uow;

    public OrderProcessor(IUnitOfWork uow)
    {
        _uow = uow;
    }

    public void PlaceOrder(Order newOrder)
    {
        try
        {
            // 1. Operation with the Orders repository
            _uow.Orders.Add(newOrder);

            // 2. Operation with the Users repository
            var user = _uow.Users.GetById(newOrder.UserId);
            user.LoyaltyPoints += 10; // Modify entity

            // ... more operations ...

            // 3. THE MOMENT OF TRUTH
            // If anything fails before this line, NOTHING is saved.
            _uow.Commit();
            
            Console.WriteLine("Order processed and saved successfully.");
        }
        catch (Exception ex)
        {
            // If an error occurs, we don't call Commit.
            // This Unit of Work scope should be discarded after failure.
            // Do not reuse a DbContext that holds pending changes.
            Console.WriteLine($"Error processing order: {ex.Message}");
        }
    }
}
Copied!

Unit of Work and Entity Framework Core

In EF Core, DbContext fulfills the role of Unit of Work and each DbSet<T> offers capabilities similar to a repository.

When you do:

context.Users.Add(user); // Repository (in-memory)
context.Orders.Add(order); // Repository (in-memory)
context.SaveChanges(); // Unit of Work (Transaction Commit)
Copied!

SaveChanges by default applies a transaction when the provider supports it. To coordinate multiple SaveChanges calls or external resources, you need an explicit transactional strategy.

However, creating an IUnitOfWork abstraction layer on top of EF Core is still useful for:

  1. Decoupling from EF Core: If you want to replace EF with Dapper or pure ADO.NET in the future.
  2. Application Boundary: Exposing domain operations can prevent higher layers from depending directly on EF Core. Persistence tests should still use a suitable database.

Advantages and Disadvantages

AdvantagesDisadvantages
Coordination: Groups changes that must be committed together when the store supports transactions.Abstraction over abstraction: Can be redundant with a modern ORM.
Centralization: Manages the database connection in a single point.Complexity: Adds another layer of indirection.
Commit Control: Prevents each repository from committing independently.Scope: Does not automatically make operations spanning multiple databases or APIs atomic.

The Unit of Work pattern is the conductor of your data layer. It ensures all instruments (repositories) play in unison and that the piece (the transaction) either ends well or ends not at all.