Unit of Work and Repository Patterns in C#

Tarik Arifoglu
5 min readAug 19, 2024

--

Introduction

In modern .NET development, ensuring separation of concerns, clean code, and efficient data handling is crucial. The Unit of Work and Repository patterns are widely used to streamline data access, while Dependency Injection (DI) have become the standard practices for enhancing performance and scalability. In this article, I’ll show how to effectively implement these patterns.

The Repository Pattern

The Repository Pattern abstracts the data access layer, providing a way to interact with the database without exposing the complexities of the underlying database operations.

Base Repository

We’ll define a base interface IRepository<T> that can handle basic CRUD operations for any entity:

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

Next, we implement the Repository<T> class:

public class Repository<T> : IRepository<T> where T : class
{
protected readonly DbContext _context;
protected readonly DbSet<T> _dbSet;

public Repository(DbContext context)
{
_context = context;
_dbSet = _context.Set<T>();
}

public async Task<List<T>> GetAllAsync()
{
return await _dbSet.ToListAsync();
}

public async Task<T> GetByIdAsync(int id)
{
return await _dbSet.FindAsync(id);
}

public async Task AddAsync(T entity)
{
await _dbSet.AddAsync(entity);
}

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

public void Delete(T entity)
{
_dbSet.Remove(entity);
}
}

Here, the async methods ensure that database operations do not block the main thread, improving performance and scalability.

Specific Repositories

For more complex entities, like User, we may need additional methods beyond basic CRUD operations. For instance, retrieving a user with their associated orders:

// User Repository
public interface IUserRepository : IRepository<User>
{
Task<User> GetUserWithOrdersAsync(int id);
}

public class UserRepository : Repository<User>, IUserRepository
{
public UserRepository(DbContext context) : base(context) { }

public async Task<User> GetUserWithOrdersAsync(int id)
{
return await _context.Set<User>()
.Include(u => u.Orders)
.FirstOrDefaultAsync(u => u.Id == id);
}
}

// Order Repository
public interface IOrderRepository : IRepository<Order>
{
Task<List<Order>> GetOrdersByUserIdAsync(int userId);
}

public class OrderRepository : Repository<Order>, IOrderRepository
{
public OrderRepository(DbContext context) : base(context) { }

public async Task<List<Order>> GetOrdersByUserIdAsync(int userId)
{
return await _dbSet.Where(o => o.UserId == userId).ToListAsync();
}
}

The Unit of Work Pattern

The Unit of Work Pattern ensures that all operations within a particular business transaction are committed or rolled back as one. We use Dependency Injection (DI) to inject repositories into the UnitOfWork class, allowing us to centralize transaction management.

public class UnitOfWork : IUnitOfWork, IDisposable
{
private readonly DbContext _context;
private readonly IServiceProvider _serviceProvider;
private bool _disposed = false;

public UnitOfWork(DbContext context, IServiceProvider serviceProvider)
{
_context = context;
_serviceProvider = serviceProvider;
}

public IUserRepository UserRepository => _serviceProvider.GetService<IUserRepository>();
public IOrderRepository OrderRepository => _serviceProvider.GetService<IOrderRepository>();

public async Task<int> CompleteAsync()
{
return await _context.SaveChangesAsync();
}

protected virtual void Dispose(bool disposing)
{
if (!_disposed)
{
if (disposing)
{
_context.Dispose();
}
}
_disposed = true;
}

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

In this UnitOfWork, we use CompleteAsync() to save all changes asynchronously. The repositories are resolved lazily via IServiceProvider to ensure they are only instantiated when necessary.

Using DTOs to Transport Entity Data

In modern applications, DTOs (Data Transfer Objects) are commonly used to transfer data between the client and server. The controller should not deal with entities directly but should rely on DTOs to ensure a clear separation between the data model and the API contract.

public class UserDto
{
public string Name { get; set; }
public string Email { get; set; }
}

public class OrderDto
{
public string ProductName { get; set; }
public decimal Price { get; set; }
}

public class CreateUserWithOrderDto
{
public UserDto User { get; set; }
public OrderDto Order { get; set; }
}

The DTOs abstract the details of the underlying entities, allowing for safer and cleaner communication between the client and server.

Service Layer: Handling Business Logic

The service layer contains the business logic of the application, ensuring that the controller only coordinates the data flow. Here’s how we structure our UserService:

public class UserService
{
private readonly IUnitOfWork _unitOfWork;

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

public async Task<UserDto> GetUserByIdAsync(int id)
{
var user = await _unitOfWork.UserRepository.GetUserWithOrdersAsync(id);
if (user == null) return null;

return new UserDto { Name = user.Name, Email = user.Email };
}

public async Task CreateUserAndOrderAsync(UserDto userDto, OrderDto orderDto)
{
// Create a User
var user = new User { Name = userDto.Name, Email = userDto.Email };
await _unitOfWork.UserRepository.AddAsync(user);

// Create an Order tied to the user
var order = new Order { ProductName = orderDto.ProductName, Price = orderDto.Price, UserId = user.Id };
await _unitOfWork.OrderRepository.AddAsync(order);

// Save both User and Order in a single transaction
await _unitOfWork.CompleteAsync();
}

public async Task DeleteUserAndOrdersAsync(int userId)
{
// Find the user
var user = await _unitOfWork.UserRepository.GetByIdAsync(userId);
if (user != null)
{
// Find and delete user's orders
var orders = await _unitOfWork.OrderRepository.GetOrdersByUserIdAsync(userId);
foreach (var order in orders)
{
_unitOfWork.OrderRepository.Delete(order);
}

// Delete the user after orders are deleted
_unitOfWork.UserRepository.Delete(user);

// Save both User and Order deletions in a single transaction
await _unitOfWork.CompleteAsync();
}
}
}
  • CreateUserAndOrderAsync: This method creates a user and an associated order in a single transaction. If any operation fails, the entire transaction is rolled back.
  • DeleteUserAndOrdersAsync: Deletes a user and all their associated orders in a single transaction.

Configuring Dependency Injection in .NET

To complete the setup, we need to register the repositories, UnitOfWork, and service classes in the DI container:

public void ConfigureServices(IServiceCollection services)
{
services.AddDbContext<ApplicationDbContext>(options =>
options.UseSqlServer(Configuration.GetConnectionString("DefaultConnection")));

// Register repositories and UnitOfWork
services.AddScoped<IUserRepository, UserRepository>();
services.AddScoped<IOrderRepository, OrderRepository>();
services.AddScoped<IUnitOfWork, UnitOfWork>();

// Register services
services.AddScoped<UserService>();
}

With this configuration, we ensure that the UnitOfWork and repositories are injected wherever they are needed.

Controller Layer: Clean and Focused on Coordination

The controller should remain clean and focused on handling HTTP requests and responses, delegating all business logic to the service layer. Here’s how you’d set up the UsersController:

[ApiController]
[Route("api/[controller]")]
public class UsersController : ControllerBase
{
private readonly UserService _userService;

public UsersController(UserService userService)
{
_userService = userService;
}

[HttpGet("{id}")]
public async Task<IActionResult> GetUserAsync(int id)
{
var user = await _userService.GetUserByIdAsync(id);
if (user == null) return NotFound();

return Ok(user);
}

[HttpPost]
public async Task<IActionResult> CreateUserWithOrderAsync([FromBody] CreateUserWithOrderDto createUserWithOrderDto)
{
await _userService.CreateUserAndOrderAsync(createUserWithOrderDto.User, createUserWithOrderDto.Order);
return CreatedAtAction(nameof(GetUserAsync), new { id = createUserWithOrderDto.User.Name }, createUserWithOrderDto.User);
}

[HttpDelete("{id}")]
public async Task<IActionResult> DeleteUserAndOrdersAsync(int id)
{
await _userService.DeleteUserAndOrdersAsync(id);
return NoContent();
}
}

Key Improvements:

  1. Transactional Consistency: Operations like creating a user and their order, or deleting a user and their associated orders, are handled within the same transaction using the Unit of Work pattern.
  2. Separation of Concerns: The controller is only responsible for coordinating requests and responses, while the service layer handles all business logic.

Conclusion

The Unit of Work and Repository patterns are powerful tools for maintaining transactional consistency and separation of concerns in your C# applications. When combined with Dependency Injection, you get a clean, scalable architecture that is both maintainable and high-performing.

By applying these patterns, you can ensure that your application handles complex business operations — such as creating and deleting related entities — in a way that guarantees either all changes are applied or none of them are, ensuring data integrity.

--

--

Responses (2)

Write a response