How to use the Unit of Work pattern in ASP.NET Core

By FoxLearn 12/31/2024 7:32:09 AM   107
The Unit of Work design pattern is an essential technique in software development, particularly when building scalable, maintainable, and reusable data access layers in ASP.NET Core applications.

In business applications, CRUD operations (Create, Read, Update, Delete) are fundamental for interacting with a data store. However, managing complex data transactions requires more than just storing and retrieving data. To create flexible, maintainable, and reusable code, developers can rely on design patterns such as Unit of Work and Repository.

What is the Unit of Work Design Pattern?

The Unit of Work design pattern ensures data integrity and consistency by guaranteeing that all changes made to multiple objects are either committed together or rolled back if an error occurs.

Implementing the Unit of Work Design Pattern in C#

To implement the Unit of Work pattern, we define an interface that specifies operations like committing and rolling back changes, along with methods to interact with repositories for CRUD operations.

Create a DbContext Class

In Entity Framework, DbContext serves as the gateway to the database.

public class CustomDbContext : DbContext
{
    public CustomDbContext(DbContextOptions<CustomDbContext> options) : base(options) { }

    public DbSet<Customer> Customers { get; set; }
}

Define the IUnitOfWork Interface

This interface defines operations such as Commit(), Rollback(), and Repository<T>(), which provides access to repositories for specific entity types.

public interface IUnitOfWork : IDisposable
{
    void Commit();
    void Rollback();
    IRepository<T> Repository<T>() where T : class;
}

Implement the UnitOfWork Class

The concrete implementation of the IUnitOfWork interface handles the transaction management, including committing or rolling back changes.

public class UnitOfWork : IUnitOfWork, IDisposable
{
    private readonly DbContext _dbContext;

    public UnitOfWork(DbContext dbContext)
    {
        _dbContext = dbContext;
    }

    public void Commit()
    {
        _dbContext.SaveChanges();
    }

    public void Rollback()
    {
        foreach (var entry in _dbContext.ChangeTracker.Entries())
        {
            if (entry.State == EntityState.Added)
                entry.State = EntityState.Detached;
        }
    }

    public IRepository<T> Repository<T>() where T : class
    {
        return new Repository<T>(_dbContext);
    }

    public void Dispose()
    {
        _dbContext.Dispose();
        GC.SuppressFinalize(this);
    }
}

Define the Repository Interface

The repository abstracts the data access logic, making it easier to interact with the data source without needing to directly manage SQL queries.

public interface IRepository<T> where T : class
{
    T GetById(object id);
    IList<T> GetAll();
    void Add(T entity);
    void Update(T entity);
}

Implement the Repository Class

The concrete implementation of the repository provides the actual logic for interacting with the database.

public class Repository<T> : IRepository<T> where T : class
{
    private readonly CustomDbContext _dbContext;
    private readonly DbSet<T> _dbSet;

    public Repository(DbContext dbContext)
    {
        _dbContext = dbContext;
        _dbSet = dbContext.Set<T>();
    }

    public T GetById(object id)
    {
        return _dbSet.Find(id);
    }

    public IList<T> GetAll()
    {
        return _dbSet.ToList();
    }

    public void Add(T entity)
    {
        _dbSet.Add(entity);
    }

    public void Update(T entity)
    {
        _dbSet.Update(entity);
    }
}

Register the DbContext

To enable dependency injection (DI) in the application, register the DbContext in the Program.cs file:

builder.Services.AddDbContextPool<CustomDbContext>(options =>
    options.UseSqlServer("YourConnectionStringHere"));

In this example, a Customer entity is managed, and several operations such as adding, updating, and retrieving customers will be performed using the Unit of Work pattern.

  • Commit: When all the operations related to a customer are successful (e.g., adding a new customer, updating their information, etc.), changes are committed to the database.
  • Rollback: If any part of the process fails (e.g., an invalid input or a database constraint violation), all changes are rolled back to maintain consistency.
public class CustomerService
{
    private readonly IUnitOfWork _unitOfWork;

    public CustomerService(IUnitOfWork unitOfWork)
    {
        _unitOfWork = unitOfWork;
    }

    public void AddCustomer(Customer customer)
    {
        var customerRepo = _unitOfWork.Repository<Customer>();
        customerRepo.Add(customer);
        _unitOfWork.Commit();
    }

    public void UpdateCustomer(Customer customer)
    {
        var customerRepo = _unitOfWork.Repository<Customer>();
        customerRepo.Update(customer);
        _unitOfWork.Commit();
    }

    public void DeleteCustomer(int customerId)
    {
        var customerRepo = _unitOfWork.Repository<Customer>();
        var customer = customerRepo.GetById(customerId);
        if (customer != null)
        {
            customerRepo.Remove(customer);
            _unitOfWork.Commit();
        }
    }
}

In this scenario, if adding or updating a customer fails (perhaps due to a database constraint), the Rollback() method would be called to revert all changes to the previous state, ensuring the database remains consistent.