In this article, we will discover the unit of work pattern, its advantages and disadvantages, and an example implementation that is completely ORM agnostic. Moreover, we will look at the unit of work’s connection with the repository pattern and we will see how easy it is to change the underlying ORM.

To download the source code for this article, you can visit our GitHub repository.

Let’s dive in.

What Is the Unit of Work Pattern?

To understand what the unit of work pattern is about, first, we should take a look at the repository pattern. In a nutshell, repositories are the data access objects and their job is to handle the communication with the database. With the unit of work pattern, we split this communication responsibility.

Support Code Maze on Patreon to get rid of ads and get the best discounts on our products!
Become a patron at Patreon!

In databases, we execute every operation inside transactions, which makes operations an indivisible unit. Without the unit of work pattern, the repositories have to manage the transaction’s lifetime. Usually, this means one transaction per operation. However, in a real-world scenario, we almost always want to group multiple operations, that form a single unit of work. This is where the pattern’s name comes from, and it basically means a logical unit of operations that must complete or fail together. Imagine making a transfer from a bank account to another account. Let’s think about the two main operations that are carried out:

  • Deduct the funds from the sender’s account
  • Add funds to the recipient’s account

Treating these two operations as separate transactions could result in deducting money from the sender’s account. However, in the event of a power outage, the money will never be credited to the recipient’s account, nor will it be returned to the sender. This outcome is unacceptable, so these two operations must execute within the same transaction, forming a single unit of work.

If we return to repositories after visiting this example, it’s clear that each repository method uses its own transaction. So we must introduce an abstraction above repositories for transaction management. This is where the implementation of the unit of work pattern will come into play. But first, let’s see the advantages of using it from a technical point of view.

Now that we discussed the theory, let’s dive into an example implementation.

How to Implement the Unit of Work Pattern?

To get started, we won’t require any ORM. Our initial implementation emphasizes the pattern itself, rather than integrating it with a specific ORM.

Data Access Abstraction

First, let’s create an abstraction over our data access layer:

public interface ITransaction : IDisposable
{
    Task CommitAsync();
    Task RollbackAsync();
}

The CommitAsync() and RollbackAsync() methods will help us implement the unit of work pattern. When we start an operation, we should request an ITransaction object from our database provider and when we finish we should call CommitAsync() on our transaction. A transaction should also be IDisposable, because if we forget to call the CommitAsync() or RollbackAsync() methods it would leave the transaction running. If we implement the Dispose() method correctly, in such a case the transaction will be rolled back.

To continue, let’s create an interface for the database:

public interface IDatabase
{
    Task<ITransaction> BeginTransactionAsync();
}

The implementation of these interfaces is tightly coupled to the ORM framework we choose, so we will skip the implementation part for now.

To make this example a bit more interesting, let’s create an abstract entity base:

public abstract class Entity
{
    public Guid Id { get; set; }
}

Now create an Order entity:

public class Order : Entity
{
}

And create an IStore interface, to be able to query and create entities:

public interface IStore : IDatabase
{
    IQueryable<TEntity> GetEntitySet<TEntity>() where TEntity : Entity;
    void Add<TEntity>(TEntity order) where TEntity : Entity;
}

Let’s also create a simple repository:

public class OrderRepository : IOrderRepository
{
    private readonly IStore _database;

    public OrderRepository(IStore database)
    {
        _database = database;
    }

    public void Add(Order order)
    {
        _database.Add(order);
    }
}

public interface IOrderRepository
{
    void Add(Order order);
}

The most important thing to note here is, that we do not start and commit a transaction inside the repository. Therefore, the addition of an entity won’t persist immediately, rather it will be held in memory until we call CommitAsync() on the underlying ITransaction.

The Unit of Work Implementation

Now that we created an abstraction for the database, let’s implement the unit of work itself, step by step:

public interface IUnitOfWork
{
    Task BeginTransactionAsync();
    Task CommitAsync();
}

First, we just declare the interface with two methods. BeginTransactionAsync() will start a transaction, and keep track of it inside the unit of work, so the caller doesn’t have to dispose of it. Moreover, the usage of transactions is an implementation detail of the unit of work, so let’s not expose that to the caller. We also declare the CommitAsync() method for persisting the changes.

Now, let’s create the actual UnitOfWork class:

public class UnitOfWork : IUnitOfWork
{
    private readonly IDatabase _database;
    private ITransaction? _currentTransaction;

    public UnitOfWork(IDatabase database)
    {
        _database = database;
    }
}

We request the IDatabase from the dependency injection container through the constructor. We also declare a nullable ITransaction that will hold the current transaction until it’s committed.

Additionally, let’s implement the BeginTransactionAsync() method:

public async Task BeginTransactionAsync()
{
    if (_currentTransaction is not null)
        throw new InvalidOperationException("A transaction has already been started.");
    _currentTransaction = await _database.BeginTransactionAsync();
}

BeginTransactionAsync() should be called at the start of every operation, but only once. So first, we check whether there is an ongoing transaction already, and if yes, we throw an exception. Otherwise, we request the database to open a new transaction for us.

Let’s finish the implementation of the UnitOfWork class with the CommitAsync() method:

public async Task CommitAsync()
{
    if (_currentTransaction is null)
        throw new InvalidOperationException("A transaction has not been started.");
    
    try
    {
        await _currentTransaction.CommitAsync();
        _currentTransaction.Dispose();
        _currentTransaction = null;
    }
    catch (Exception)
    {
        if (_currentTransaction is not null)
            await _currentTransaction.RollbackAsync();
        throw;
    }
}

Similarly to the BeginTransactionAsync() method, we start by checking the _currentTransaction, just now we check whether it is null. If it is, we throw an exception, since we don’t have a transaction to commit. After, we execute the commit inside a try-catch block, and if there were no exceptions during the commit, we dispose of the transaction and set the value of _currentTransaction to null to fully clean up.

Inside the catch block, we check _currentTransaction again before calling its RollbackAsync() method. This is not strictly necessary, however, it is a good practice to write code defensively. The database connection might fail, and its failure could lead to the disposal and nullification of the transaction object. Therefore, it is good practice to recheck it before performing a rollback, as the transaction may have already been rolled back and closed by the database.

Configuring Dependency Injection

To be able to configure the dependency injection framework, let’s create a dummy transaction first:

public class Transaction : ITransaction
{
    public Task CommitAsync() => Task.CompletedTask;

    public Task RollbackAsync() => Task.CompletedTask;

    public void Dispose()
    {
    }
}

We leave it empty because we are not using an actual database, so no need to hook things up properly.

Also, we need to create a database:

public class DummyDatabase : IStore
{
    private readonly List<Order> _orders = new List<Order>();
    
    public IQueryable<TEntity> GetEntitySet<TEntity>() where TEntity : Entity => _orders.AsQueryable() as IQueryable<TEntity>;
    
    public Task<ITransaction> BeginTransactionAsync()
    {
        return Task.FromResult(new EfTransaction(null) as ITransaction);
    }
    
    public void Add<TEntity>(TEntity order) where TEntity : Entity
    {
        _orders.Add(order as Order);
    }
}

Here we can implement things simply again. During testing, we will mock these anyway. Also, we apply casting, because we can be sure that only an Order object will be passed right now. But in a real-world scenario, we would have a way of determining which list to use.

With our dummy data access layer in place, we can connect it to the unit of work via the dependency injection framework. Let’s install the Microsoft.Extensions.DependencyInjection NuGet package and create the ServiceCollection:

var services = new ServiceCollection();

services.AddScoped<DummyDatabase>();
services.AddScoped<IDatabase, DummyDatabase>(sp => sp.GetRequiredService<DummyDatabase>());
services.AddScoped<IStore, DummyDatabase>(sp => sp.GetRequiredService<DummyDatabase>());
services.AddScoped<IUnitOfWork, UnitOfWork.DataAccess.UnitOfWork>();
services.AddScoped<IOrderRepository, OrderRepository>();

The NuGet package installation and ServiceCollection creation is necessary because we are using a simple console application, but this is created automatically in the ASP.NET Core templates.

We register the DummyDatabase first, but everyone should request either the IDatabase or IStore interface. Because we are requesting the DummyDatabase itself when registering the interfaces it will be the same instance for both interfaces.

It’s also important to consider the lifetime that we register. By using the Scoped lifetime, inside the same scope (or request for web applications) the UnitOfWork instance will remain the same. In most cases this is the desirable behavior, however, we can also take advantage of the Transient lifetime. The only lifetime we should avoid is the Singleton, since if we forget to complete the unit of work, the transaction will remain open for the entire lifetime of the application. 

Testing The Unit of Work Implementation

Let’s see how should we use our unit of work implementation:

var repository = serviceProvider.GetRequiredService<IOrderRepository>();
var unitOfWork = serviceProvider.GetRequiredService<UnitOfWork.DataAccess.IUnitOfWork>();

unitOfWork.BeginTransactionAsync();
repository.Add(new Order());
unitOfWork.CommitAsync();

We request the IOrderRepository and the IUnitOfWork interfaces from the DI container. Then we call the BeginTransactionAsync() method, followed by the operation, namely creating a new order. Finally, we call CommitAsync(). This way, no database operation occurs until CommitAsync() is called.

Now let’s look at the main strength of the pattern, by introducing ORMs, and switching between them.

Introducing ORMs

Using ORM frameworks in conjunction with the unit of work pattern makes it easier to handle complex data persistence scenarios. Let’s take a look at how to integrate the unit of work pattern with different popular ORM frameworks.

Unit of Work With Entity Framework Core

Let’s add Entity Framework Core by installing its NuGet package:

dotnet add package Microsoft.EntityFrameworkCore

Next, let’s extend the Transaction class we created earlier to accept an IDbContextTransaction, and rename it to EfTransaction:

public class EfTransaction : ITransaction
{
    private IDbContextTransaction _dbContextTransaction;

    public EfTransaction(IDbContextTransaction dbContextTransaction)
    {
        _dbContextTransaction = dbContextTransaction;
    }

    public Task CommitAsync() => _dbContextTransaction.CommitAsync();

    public Task RollbackAsync() => _dbContextTransaction.RollbackAsync();

    public void Dispose()
    {
        _dbContextTransaction?.Dispose();
        _dbContextTransaction = null!;
    }
}

Let’s also create a DbContext with an Orders DbSet that implements our previously defined IStore interface:

public class AppDbContext : DbContext, IStore
{
    public DbSet<Order> Orders { get; set; }
    public async Task<ITransaction> BeginTransaction()
    {
        var transaction = await Database.BeginTransactionAsync();
        return new EfTransaction(transaction);
    }
    public IQueryable<TEntity> GetEntitySet<TEntity>() where TEntity : Entity => Set<TEntity>();
    
    public void Add<TEntity>(TEntity entity) where TEntity : Entity
    {
        Set<Entity>().Add(entity);
    }
}

Now, we don’t need the DummyDatabase, since we connected the unit of work to Entity Framework Core. The rest of the application can remain unchanged. This is possible because the unit of work pattern does a great job of separating data access from our application layer.

Unit of Work With Entity Framework Core Utilizing DbContext

In the previous section, we integrated Entity Framework Core to suit our current architecture the most. However, the DbContext class already implements the unit of work pattern. Moreover, it does it without opening a transaction prematurely, by using an in-memory change tracker. The job of this change tracker is to keep track of entities that are added, modified, or deleted then generate and execute the appropriate SQL only when we call the SaveChangesAsync() method.

Let’s create a new IUnitOfWork interface for Entity Framework Core:

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

The SaveChangesAsync() method’s signature is identical to what the DbContext class already declared, so we just simply have to add our new interface to the interfaces implemented by AppDbContext. Because of the change tracker, we won’t have to open transactions manually since it will store changes in memory for us until we are ready to commit.

Let’s update the dependency injection registrations:

services.AddScoped<AppDbContext>();
services.AddScoped<IDatabase, AppDbContext>(sp => sp.GetRequiredService<AppDbContext>());
services.AddScoped<IStore, AppDbContext>(sp => sp.GetRequiredService<AppDbContext>());
services.AddScoped<UnitOfWork.DataAccess.EntityFramework.IUnitOfWork, AppDbContext>(sp => 
    sp.GetRequiredService<AppDbContext>());

We add the AppDbContext to the container, but in our application, we should not request it. We register it because we want to pass the same instance for both the IDatabase and IUnitOfWork interfaces and this way their lifetime can be managed by the DI framework. To save an entity we simply just add it to the DbContext and call SaveChangesAsync() on the unit of work, which is actually the same DbContext, just abstracted:

var db = serviceProvider.GetRequiredService<IStore>();
var unitOfWork = serviceProvider.GetRequiredService<UnitOfWork.DataAccess.EntityFramework.IUnitOfWork>();

db.Add(new Order());
unitOfWork.SaveChangesAsync();

Now let’s see how easy it is to switch to another popular ORM, Dapper.

Unit of Work With Dapper

Let’s create another implementation of IStore, this time call it DapperContext:

public class DapperContext : IStore
{
    private readonly SqlConnection _sqlConnection;
    
    public DapperContext(SqlConnection sqlConnection)
    {
        _sqlConnection = sqlConnection;
    }

    public async Task<ITransaction> BeginTransactionAsync()
    {
        var sqlTransaction = await _sqlConnection.BeginTransactionAsync();
        
        return new DapperTransaction(sqlTransaction);
    }

    public IQueryable<TEntity> GetEntitySet<TEntity>() where TEntity : Entity
    {
        return _sqlConnection.Query<TEntity>($"SELECT * FROM {GetPluralName<TEntity>()}").AsQueryable();
    }

    public void Add<TEntity>(TEntity order) where TEntity : Entity
    {
        _sqlConnection.Execute($"INSERT INTO {GetPluralName<TEntity>()} (Id) VALUES (@Id)", order.Id);
    }

    private static string GetPluralName<TEntity>() where TEntity : Entity
    {
        return typeof(TEntity).Name + "s";
    }
}

The SqlConnection will be provided by the dependency injection framework. With Dapper, it’s a bit difficult to implement the GetEntitySet() method, since Dapper is not able to defer the evaluation of queries like Entity Framework Core. So when calling the GetEntitySet() method, Dapper will always go to the database and query the table. In a real-life scenario, this is suboptimal behavior, but this code is just for demonstration purposes.

The GetPluralName() method is also simplified, it’s just an example of how to use an entity’s name as a table name dynamically.

The second thing that we have to do, is create the DapperTransaction class:

public class DapperTransaction : ITransaction
{
    private DbTransaction _dbTransaction;

    public DapperTransaction(DbTransaction dbTransaction)
    {
        _dbTransaction = dbTransaction;
    }

    public Task CommitAsync() => _dbTransaction.CommitAsync();

    public Task RollbackAsync() => _dbTransaction.RollbackAsync();

    public void Dispose()
    {
        _dbTransaction?.Dispose();
        _dbTransaction = null!;
    }
}

And this is all. By changing the dependency injection registrations, we can easily switch between ORMs, without changing any application layer code:

services.AddScoped<IDatabase, AppDbContext>();
services.AddScoped<IStore, AppDbContext>();
//OR
services.AddScoped<IDatabase, DapperContext>();
services.AddScoped<IStore, DapperContext>();

Lastly, let’s review some best practices when using the unit of work pattern.

Unit of Work Best Practices

Throughout the article, we talked many times about dependency injection. It is a very important concept when utilizing the unit of work pattern. It enables us to switch implementations fast and easily, even at runtime. Moreover, it helps us manage the lifetime of the unit of work, so we will never forget to close a transaction, even in case of exceptions.

To reflect more on the unit of work’s lifetime, it’s a good idea to keep it as short as possible. Because every unit of work can potentially contain an open transaction, thus a live connection to the database, it is important to close these as soon as possible. If we leave transactions open for a long period of time, we could exhaust the database, and increase the chance of a concurrency exception. Or even worse, if we use pessimistic concurrency, we could seriously degrade performance.

Speaking of concurrency, it’s a good idea to use optimistic concurrency in conjunction with the unit of work pattern to maximize throughput. However, it is crucial to keep unit of work lifetimes short to decrease the chance of a conflict.

Why Should We Use the Unit of Work Pattern?

When speaking about the pros of the unit of work pattern it’s hard to distinguish it from the repository pattern’s, so let’s discuss the advantages of using both.

Abstraction

Abstraction can be considered a benefit because it helps us in many ways. Firstly, it makes our code loosely coupled by abstracting the details of data access, hence our application code can remain database agonistic. Because of this, many things become easier. Mocking the database when writing unit tests is just a few lines of code for any use case that we implement. If we change our mind about the underlying database and want to switch to another, it won’t affect our application logic. The same is true for changing ORM frameworks.

Data Consistency

Because of the extracted transaction handling it’s really convenient to group operations together and commit or roll them back as a single unit. This greatly improves data consistency, since nothing will be saved in case of an exception, but it is guaranteed that everything will be saved if there is no error. So our data will never get stuck in an invalid state.

Performance

Usually, the slowest element of a computer system is the communication channel. In the case of modern web applications, mostly this channel is the internet itself. The unit of work pattern also helps us with this. Since it groups operations together and sends them through the wire in a batch, in most cases it will be faster than committing transactions one by one. It is also unnecessary to open and close multiple connections and manage new transactions one after another, thus reducing overhead on our database server.

However, as with everything in software development, this pattern also has disadvantages that we must evaluate before integrating a pattern into a specific project.

What Are the Drawbacks of the Unit of Work Pattern?

As we saw, the unit of work pattern has some significant advantages. Yet, it’s not flawless. However, most of the time its pros outweigh its drawbacks. Let’s summarize them.

Complexity

Introducing abstraction always increases code complexity too. Sometimes this complexity increase is not justified, especially in the case of simpler applications. Let’s assume that our application is a single-user console application in a corporate intranet. In this case, it’s perfectly understandable if we don’t want to overengineer the solution, and simply use the database directly.

Learning Curve

The unit of work pattern has a moderate learning curve for someone who is not familiar with the concept. For developers new to this pattern it requires a mindset change, how they think about data persistence and transactions. This could potentially introduce more complexity on top of code complexity by the abstractions.

Delayed State Changes

By grouping operations together, we have to give up some advantages of direct database access. This is also connected to the required mindset change we discussed earlier. Let’s take an example scenario, where we want to create two related entities in the same transaction: an order and a payment. Assume that we have database-generated IDs for both entities. In our code, when we create an order, it won’t possess an ID at that moment. This is because the ID will be generated by the database, but the unit of work’s transaction has not been finalized as of now. When creating a payment, we won’t be able to link it to the order, because it doesn’t have an ID yet.

Another issue related to delays occurs when we retrieve an entity from the database, make modifications within the unit of work, and then re-query it within the same transaction. In this scenario, it retains the previous values because we haven’t saved the changes yet.

Conclusion

In this article, we did a deep dive into the unit of work pattern. We started with an explanation of the pattern and summarized the pros and cons of using it. Then we implemented the pattern without using any specific ORM and tested the behavior of deferred saving. After, we added Entity Framework Core and Dapper to our implementation, and noticed how easy the switch is between them when we are using the unit of work pattern.

Liked it? Take a second to support Code Maze on Patreon and get the ad free reading experience!
Become a patron at Patreon!