Repository and Unit of Work Patterns: Implementation and Benefits
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.DbContextis 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
DbContextis highly optimized and easy to mock withInMemoryorSqliteproviders 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
- Microsoft Learn: Design the infrastructure persistence layer (eShopOnContainers)
- Martin Fowler: Repository Pattern
- Martin Fowler: Unit of Work Pattern
- Blog: To Repository or Not to Repository? (Ardalis)
- Blog: Unit of Work and Repository Pattern with EF Core
C# Interview Series
- Part 1: Key Concepts and Knowledge
- Part 2: LINQ and Sorting
- Part 3: LeetCode Tips and Tricks
- Part 4: Entity Framework Core Mastery
- Part 5: ADO.NET Fundamentals
- Part 6: SQL Server T-SQL Fundamentals
- Part 7: Clean Architecture: Principles, Layers, and Best Practices
- Part 8: N-Tier Architecture: Structure, Layers, and Beginner Guide
- Part 9: Repository and Unit of Work Patterns: Implementation and Benefits
- Part 10: TDD and Unit Testing in .NET: Production-Ready Strategies
- Part 11: xUnit Testing: Facts, Theories, and Data-Driven Tests
- Part 12: FluentAssertions: Write More Readable Unit Tests
Leave a comment