5 minute read

8 min read 1642 words

The Repository and Unit of Work patterns are two of the most widely discussed architectural patterns in the .NET ecosystem. When used together, they help decouple your business logic from the database and ensure data integrity. This post explains these concepts, how to implement them, and when you should (or shouldn’t) use them.


1. The Repository Pattern

What is it?

A Repository acts as an abstraction between the Domain (Business Logic) and the Data Mapping layers (EF Core, Dapper). It mediates between the domain and data mapping layers using a collection-like interface for accessing domain objects.

In simpler terms: A Repository makes your database look like an in-memory collection (like a List).

Why use it?

  • Decoupling: Your business logic doesn’t need to know if you’re using EF Core, ADO.NET, or an external API.
  • Centralized Logic: Query logic for a specific entity is in one place, preventing duplication.
  • Testability: You can easily mock a repository interface (IUserRepository) to write unit tests for your services without a real database.

2. The Unit of Work Pattern

What is it?

The Unit of Work pattern maintains a list of objects affected by a business transaction and coordinates the writing out of changes and the resolution of concurrency problems.

In .NET, a Unit of Work ensures that multiple repository operations are treated as a single transaction.

Why use it?

  • Atomic Transactions: It ensures that either all changes are saved to the database, or none are (ACID properties).
  • Efficiency: It reduces database roundtrips by batching multiple commands into a single SaveChanges() call.
  • Consistency: It provides a single point of entry for saving changes across different repositories.

3. Visual Representation (ASCII Diagram)

    +-----------------------+
    |   Service (Business)  |
    +-----------|-----------+
                |
                v
    +-----------|-----------+
    |     Unit of Work      | <--- Manages Transaction
    +-----------|-----------+
         /      |      \
        v       v       v
    +-------+-------+-------+
    | RepoA | RepoB | RepoC | <--- Abstractions over DB
    +-------+-------+-------+
         \      |      /
          v     v     v
    +-----------------------+
    |   Database Context    | <--- Actual Data Access (EF)
    +-----------------------+

4. Simple Implementation Example

1. The Repository (Production-Ready)

// The Interface (Core Layer)
public interface IUserRepository
{
    Task<User?> GetByIdAsync(int id, CancellationToken cancellationToken = default);
    Task AddAsync(User user, CancellationToken cancellationToken = default);
}

// The Implementation (Infrastructure Layer)
public class UserRepository : IUserRepository
{
    private readonly MyDbContext _context;
    public UserRepository(MyDbContext context) 
    {
        _context = context ?? throw new ArgumentNullException(nameof(context));
    }

    public async Task<User?> GetByIdAsync(int id, CancellationToken cancellationToken = default) 
    {
        // Using FindAsync for primary key lookups
        return await _context.Users.FindAsync(new object[] { id }, cancellationToken);
    }

    public async Task AddAsync(User user, CancellationToken cancellationToken = default) 
    {
        ArgumentNullException.ThrowIfNull(user);
        await _context.Users.AddAsync(user, cancellationToken);
    }
}

2. The Unit of Work (Production-Ready)

public interface IUnitOfWork : IDisposable, IAsyncDisposable
{
    IUserRepository Users { get; }
    IOrderRepository Orders { get; }
    Task<int> CompleteAsync(CancellationToken cancellationToken = default);
}

public class UnitOfWork : IUnitOfWork
{
    private readonly MyDbContext _context;
    
    // Lazy initialization of repositories is also a common senior pattern
    public IUserRepository Users { get; }
    public IOrderRepository Orders { get; }

    public UnitOfWork(MyDbContext context)
    {
        _context = context ?? throw new ArgumentNullException(nameof(context));
        Users = new UserRepository(_context);
        Orders = new OrderRepository(_context);
    }

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

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

    public async ValueTask DisposeAsync()
    {
        await _context.DisposeAsync();
        GC.SuppressFinalize(this);
    }
}

3. Usage in a Service (Production Style)

public class OrderService
{
    private readonly IUnitOfWork _unitOfWork;
    private readonly ILogger<OrderService> _logger;

    public OrderService(IUnitOfWork unitOfWork, ILogger<OrderService> logger)
    {
        _unitOfWork = unitOfWork ?? throw new ArgumentNullException(nameof(unitOfWork));
        _logger = logger ?? throw new ArgumentNullException(nameof(logger));
    }

    public async Task CreateOrderAsync(int userId, Order order, CancellationToken ct = default)
    {
        try 
        {
            var user = await _unitOfWork.Users.GetByIdAsync(userId, ct);
            if (user == null)
            {
                _logger.LogWarning("Order creation failed: User {UserId} not found", userId);
                throw new KeyNotFoundException($"User with ID {userId} not found.");
            }

            await _unitOfWork.Orders.AddAsync(order, ct);
            
            // Atomic transaction: Save all changes or none
            await _unitOfWork.CompleteAsync(ct);
            _logger.LogInformation("Order {OrderId} created for user {UserId}", order.Id, userId);
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error occurred while creating order for user {UserId}", userId);
            throw; // Re-throw to handle at higher level (e.g., Global Exception Handler)
        }
    }
}

5. Repository vs. DbContext (The Big Debate)

In modern EF Core, many developers argue that the Repository and Unit of Work patterns are redundant.

  • DbSet<T> is already a Repository.
  • DbContext is already a Unit of Work.

When to use Repository/UoW?

  • Large, Complex Systems: When you need a strict separation between domain logic and infrastructure.
  • Multi-Provider Support: If you plan to switch between EF Core and Dapper for different queries.
  • Unit Testing Purists: If you want to test business logic without even a dependency on Microsoft.EntityFrameworkCore.

When to skip them (Direct DbContext)?

  • Standard Web APIs: EF Core’s DbContext is highly optimized and easy to mock with InMemory or Sqlite providers for testing.
  • Simple CRUD: It adds significant boilerplate (“Mapping Hell”) for little gain.

6. Pros and Cons

Pros

  • Separation of Concerns: Keeps your domain “clean” of data access logic.
  • Consistency: Every database call follows the same pattern.
  • Mocking: Easier to unit test services using simple interface mocks.

Cons

  • Boilerplate: You have to create an interface and a class for every entity.
  • Indirection: Harder to trace the actual SQL being executed through multiple layers.
  • Leakage: Sometimes EF Core features (like IQueryable) leak into the repository interface, defeating the purpose of abstraction.

7. Summary: Should you use it?

If you are a beginner, start by using DbContext directly in your services. It’s the standard way in modern .NET and reduces complexity.

As your application grows and you find yourself repeating complex LINQ queries or struggling with unit tests, consider introducing a Repository for those specific cases. Only add a Unit of Work if you have complex business transactions that span across multiple entities.


8. References & Further Reading


C# Interview Series

Leave a comment