Simplifying Data Access in C# with the Unit of Work Pattern

 


Introduction

When building enterprise applications in C#, efficiently managing data access is crucial for maintainability and performance. As applications grow, coordinating multiple repositories becomes increasingly complex. The Unit of Work pattern offers an elegant solution to this challenge, allowing you to simplify your service layer while maintaining a clean separation of concerns.

In this post, I'll show you how to implement the Unit of Work pattern in a .NET application to coordinate multiple generic repositories.

The Problem: Repository Proliferation

Imagine you're working on an approval workflow system. You have several related entities: ApprovalRequest, ApprovalAction, and User. Following the repository pattern, you might create a separate repository for each:

public interface IRepository<T> where T : class
{
    Task<T> GetByIdAsync(object id);
    Task AddAsync(T entity);
    // Other CRUD operations
}

Your service might look like this:

public class ApprovalService
{
    private readonly IRepository<ApprovalAction> _approvalActionRepo;
    private readonly IRepository<ApprovalRequest> _approvalRequestRepo;
    private readonly IRepository<User> _userRepo;
    
    public ApprovalService(
        IRepository<ApprovalAction> approvalActionRepo,
        IRepository<ApprovalRequest> approvalRequestRepo,
        IRepository<User> userRepo)
    {
        _approvalActionRepo = approvalActionRepo;
        _approvalRequestRepo = approvalRequestRepo;
        _userRepo = userRepo;
    }
    
    // Service methods
}

This approach has several drawbacks:

  • Constructor injection becomes unwieldy with many repositories
  • Transaction management across repositories is difficult
  • Service classes risk becoming bloated
  • Testing requires mocking multiple repositories

The Solution: Unit of Work Pattern

The Unit of Work pattern addresses these issues by providing a single point of entry to your data access layer:

public interface IUnitOfWork
{
    IRepository<ApprovalAction> ApprovalActions { get; }
    IRepository<ApprovalRequest> ApprovalRequests { get; }
    IRepository<User> Users { get; }
    
    Task SaveChangesAsync();
}

Your service now needs only one dependency:

public class ApprovalService
{
    private readonly IUnitOfWork _unitOfWork;
    
    public ApprovalService(IUnitOfWork unitOfWork)
    {
        _unitOfWork = unitOfWork;
    }
    
    public async Task<ApprovalResult> ProcessApprovalAsync(int requestId, int userId, string action)
    {
        var request = await _unitOfWork.ApprovalRequests.GetByIdAsync(requestId);
        var user = await _unitOfWork.Users.GetByIdAsync(userId);
        
        var approvalAction = new ApprovalAction
        {
            RequestId = requestId,
            UserId = userId,
            Action = action,
            Timestamp = DateTime.UtcNow
        };
        
        await _unitOfWork.ApprovalActions.AddAsync(approvalAction);
        await _unitOfWork.SaveChangesAsync();
        
        return new ApprovalResult { /* result */ };
    }
}

Implementation: Bringing It All Together

Let's implement the complete solution:

Step 1: Create the Repository Interface and Implementation

public interface IRepository<T> where T : class
{
    Task<T> GetByIdAsync(object id);
    Task<IEnumerable<T>> GetAllAsync();
    Task AddAsync(T entity);
    void Update(T entity);
    void Delete(T entity);
}

public class Repository<T> : IRepository<T> where T : class
{
    protected readonly ApplicationDbContext _context;
    protected readonly DbSet<T> _dbSet;
    
    public Repository(ApplicationDbContext context)
    {
        _context = context;
        _dbSet = context.Set<T>();
    }
    
    public async Task<T> GetByIdAsync(object id)
    {
        return await _dbSet.FindAsync(id);
    }
    
    public async Task<IEnumerable<T>> GetAllAsync()
    {
        return await _dbSet.ToListAsync();
    }
    
    public async Task AddAsync(T entity)
    {
        await _dbSet.AddAsync(entity);
    }
    
    public void Update(T entity)
    {
        _dbSet.Attach(entity);
        _context.Entry(entity).State = EntityState.Modified;
    }
    
    public void Delete(T entity)
    {
        if (_context.Entry(entity).State == EntityState.Detached)
        {
            _dbSet.Attach(entity);
        }
        _dbSet.Remove(entity);
    }
}

Step 2: Implement the Unit of Work

public class UnitOfWork : IUnitOfWork, IDisposable
{
    private readonly ApplicationDbContext _context;
    private readonly IServiceProvider _serviceProvider;
    private bool _disposed = false;
    
    // Lazy-loaded repositories
    private IRepository<ApprovalAction> _approvalActions;
    private IRepository<ApprovalRequest> _approvalRequests;
    private IRepository<User> _users;
    
    public UnitOfWork(ApplicationDbContext context, IServiceProvider serviceProvider)
    {
        _context = context;
        _serviceProvider = serviceProvider;
    }
    
    public IRepository<ApprovalAction> ApprovalActions => 
        _approvalActions ??= _serviceProvider.GetRequiredService<IRepository<ApprovalAction>>();
    
    public IRepository<ApprovalRequest> ApprovalRequests => 
        _approvalRequests ??= _serviceProvider.GetRequiredService<IRepository<ApprovalRequest>>();
    
    public IRepository<User> Users => 
        _users ??= _serviceProvider.GetRequiredService<IRepository<User>>();
    
    public async Task SaveChangesAsync()
    {
        await _context.SaveChangesAsync();
    }
    
    public void Dispose()
    {
        Dispose(true);
        GC.SuppressFinalize(this);
    }
    
    protected virtual void Dispose(bool disposing)
    {
        if (!_disposed)
        {
            if (disposing)
            {
                _context.Dispose();
            }
            _disposed = true;
        }
    }
}

Step 3: Configure Dependency Injection in Program.cs

public static class Program
{
    public static void Main(string[] args)
    {
        var builder = WebApplication.CreateBuilder(args);

        // Register your DbContext
        builder.Services.AddDbContext<ApplicationDbContext>(options =>
            options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection")));
        
        // Register repositories
        builder.Services.AddScoped(typeof(IRepository<>), typeof(Repository<>));
        
        // Register UnitOfWork
        builder.Services.AddScoped<IUnitOfWork, UnitOfWork>();
        
        // Register your services
        builder.Services.AddScoped<ApprovalService>();
        
        // Add other services
        builder.Services.AddControllers();
        
        var app = builder.Build();
        
        app.MapControllers();
        
        app.Run();
    }
}

Benefits of the Unit of Work Pattern

This implementation offers several advantages:

  1. Simplified Service Layer: Services only need to inject a single dependency (IUnitOfWork) regardless of how many repositories they use.

  2. Transaction Management: The SaveChangesAsync method ensures that all operations within a transaction either succeed or fail together.

  3. Lazy Loading: Repositories are only instantiated when actually used, improving performance.

  4. Testability: Mocking a single UnitOfWork is much simpler than mocking multiple repositories.

  5. Centralized Data Access: All database interactions flow through a single point, making it easier to add cross-cutting concerns like logging or caching.

When to Use the Unit of Work Pattern

The Unit of Work pattern is most beneficial when:

  • Your application needs to coordinate multiple repositories
  • You want to maintain transaction integrity across operations
  • You're working with Entity Framework or another ORM
  • Your service layer is growing unwieldy with multiple repository injections

However, for very simple applications with only one or two repositories, this pattern may introduce unnecessary complexity.

Conclusion

By implementing the Unit of Work pattern in your C# applications, you can significantly reduce the complexity of your service layer while maintaining clean separation of concerns. This approach scales well as your application grows, providing a sustainable way to manage data access across multiple repositories.

The code samples in this post provide a solid foundation that you can adapt to your specific needs. For more complex scenarios, consider extending the pattern with additional features like query specifications or read-only repositories.

Happy coding!

Comments

Popular posts from this blog

Yes, Blazor Server can scale!

Blazor - Displaying an Image

Blazor new and improved Search Box