
Learn the Repository and Unit of Work pattern in C# with runnable EF Core examples, best practices, and pitfalls. Master clean data access today.
If you've ever felt your data access code turn into a tangled mess of DbContext calls scattered across controllers, you're not alone. The repository pattern in C# is one of the most searched-for solutions to this exact problem — and when you pair it with the Unit of Work pattern, you get a clean, testable, and maintainable data access layer that scales with your application. In this tutorial, you'll learn exactly how to implement both patterns correctly using Entity Framework Core, why they matter, and the common pitfalls that trip up even experienced developers.
Whether you're a beginner searching for "how to use the repository pattern," an intermediate developer looking for best practices, or a senior engineer weighing the trade-offs, this guide has you covered with practical, runnable examples.
What Is the Repository Pattern in C#?
The repository pattern in C# is a design pattern that mediates between your domain logic and your data access layer. Think of a repository as an in-memory collection of domain objects. Your application code asks the repository for data or tells it to save data, without ever knowing whether that data lives in SQL Server, PostgreSQL, an in-memory list, or a REST API.
The core idea is abstraction. Instead of writing _context.Customers.Where(c => c.IsActive).ToList() everywhere, you write _customerRepository.GetActiveCustomers(). The details of how that query runs are hidden behind an interface.
Here's a simple generic repository interface, one of the most common starting points when people search for "generic repository c#":
public interface IRepository<T> where T : class
{
Task<T?> GetByIdAsync(int id);
Task<IEnumerable<T>> GetAllAsync();
Task<IEnumerable<T>> FindAsync(Expression<Func<T, bool>> predicate);
Task AddAsync(T entity);
void Update(T entity);
void Remove(T entity);
}
Notice that there's no SaveChanges method here. That's intentional — and it's where the Unit of Work pattern enters the picture.
What Is the Unit of Work Pattern?
The Unit of Work pattern in C# keeps track of everything you do during a business transaction that can affect the database, then coordinates writing out all those changes as a single atomic operation. In other words, it groups multiple repository operations into one transaction so they either all succeed or all fail together.
Why does this matter? Imagine you're processing an order: you need to insert an Order, decrement Product stock, and add a PaymentRecord. If the payment insert fails after the stock update succeeds, you've corrupted your data. The Unit of Work ensures these operations commit together or roll back together.
public interface IUnitOfWork : IDisposable
{
IRepository<Customer> Customers { get; }
IRepository<Order> Orders { get; }
IRepository<Product> Products { get; }
Task<int> CompleteAsync();
}
The single CompleteAsync() call is the atomic commit. Every repository shares the same database context, so their changes are saved in one transaction.
Implementing the Repository Pattern with EF Core
Here's where theory becomes practice. Entity Framework Core actually already implements the repository and Unit of Work patterns internally — DbSet<T> is a repository, and DbContext is a Unit of Work. But wrapping them adds value when you want to centralize query logic, improve testability, and decouple your business layer from EF Core specifically.
Let's build a concrete generic repository using EF Core:
public class Repository<T> : IRepository<T> where T : class
{
protected readonly AppDbContext _context;
private readonly DbSet<T> _dbSet;
public Repository(AppDbContext context)
{
_context = context;
_dbSet = context.Set<T>();
}
public async Task<T?> GetByIdAsync(int id) =>
await _dbSet.FindAsync(id);
public async Task<IEnumerable<T>> GetAllAsync() =>
await _dbSet.ToListAsync();
public async Task<IEnumerable<T>> FindAsync(
Expression<Func<T, bool>> predicate) =>
await _dbSet.Where(predicate).ToListAsync();
public async Task AddAsync(T entity) =>
await _dbSet.AddAsync(entity);
public void Update(T entity) =>
_dbSet.Update(entity);
public void Remove(T entity) =>
_dbSet.Remove(entity);
}
This class handles the common CRUD operations for any entity type. When you need entity-specific queries, you inherit from it:
public interface ICustomerRepository : IRepository<Customer>
{
Task<IEnumerable<Customer>> GetActiveCustomersAsync();
}
public class CustomerRepository : Repository<Customer>, ICustomerRepository
{
public CustomerRepository(AppDbContext context) : base(context) { }
public async Task<IEnumerable<Customer>> GetActiveCustomersAsync() =>
await _context.Set<Customer>()
.Where(c => c.IsActive)
.OrderBy(c => c.Name)
.ToListAsync();
}
Building the Unit of Work Implementation
The Unit of Work ties all repositories together through a shared DbContext. This is the piece that makes the whole pattern coherent — and it's a frequent search for "unit of work pattern c#" done properly.
public class UnitOfWork : IUnitOfWork
{
private readonly AppDbContext _context;
public IRepository<Customer> Customers { get; }
public IRepository<Order> Orders { get; }
public IRepository<Product> Products { get; }
public UnitOfWork(AppDbContext context)
{
_context = context;
Customers = new Repository<Customer>(context);
Orders = new Repository<Order>(context);
Products = new Repository<Product>(context);
}
public async Task<int> CompleteAsync() =>
await _context.SaveChangesAsync();
public void Dispose() => _context.Dispose();
}
Now register everything in your dependency injection container. In a modern .NET minimal API or Program.cs:
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseSqlServer(builder.Configuration.GetConnectionString("Default")));
builder.Services.AddScoped<IUnitOfWork, UnitOfWork>();
Registering the Unit of Work as scoped is critical — it ensures every request gets exactly one DbContext instance shared across all repositories, which is what makes the atomic transaction work.
Putting It All Together: A Real Service Example
Here's how a service class consumes the Unit of Work to process an order atomically. This demonstrates why the pattern earns its keep:
public class OrderService
{
private readonly IUnitOfWork _unitOfWork;
public OrderService(IUnitOfWork unitOfWork) =>
_unitOfWork = unitOfWork;
public async Task PlaceOrderAsync(int customerId, int productId, int qty)
{
var product = await _unitOfWork.Products.GetByIdAsync(productId);
if (product is null || product.Stock < qty)
throw new InvalidOperationException("Insufficient stock.");
product.Stock -= qty;
_unitOfWork.Products.Update(product);
var order = new Order
{
CustomerId = customerId,
ProductId = productId,
Quantity = qty,
CreatedAt = DateTime.UtcNow
};
await _unitOfWork.Orders.AddAsync(order);
// Both the stock update and the new order commit together.
await _unitOfWork.CompleteAsync();
}
}
If anything throws before CompleteAsync(), nothing is written to the database. The stock reduction and order creation are one atomic unit — exactly the guarantee the Unit of Work pattern promises.
Repository Pattern Best Practices
Searching for "repository pattern best practices" is popular for good reason — the pattern is easy to get wrong. Follow these guidelines to keep your data access layer clean:
- Return domain entities or DTOs, not
IQueryable. ExposingIQueryableleaks EF Core query logic into your business layer and defeats the purpose of the abstraction. - Keep repositories focused on persistence. Business rules belong in services or the domain model, not inside repository methods.
- Use async methods throughout. Data access is I/O-bound, so
async/awaitkeeps your app responsive and scalable under load. - Prefer specific repositories over a purely generic one when queries get complex. A generic repository is a great base, but real applications need named, intention-revealing methods.
- Register the Unit of Work as scoped so the
DbContextlifetime matches the request.
Common Pitfalls to Avoid
Even with a solid understanding, developers stumble on the same issues. Here are the traps to watch for:
- Over-abstracting EF Core. Remember,
DbContextis already a Unit of Work. If your app is small and won't switch data providers, adding these layers may be unnecessary ceremony. Use the pattern when the testability and organization benefits are real. - The leaky generic repository. A one-size-fits-all generic repository can't express complex joins, projections, or eager loading cleanly. Don't force every query through it.
- Multiple
DbContextinstances. If repositories accidentally receive different context instances,CompleteAsync()won't save all changes together, silently breaking your atomicity guarantee. - Ignoring the change tracker. EF Core tracks entities automatically. Calling
Update()on an already-tracked entity can cause redundant SQL. Understand how tracking works before wrapping it. - Testing against the real database. One big win of this pattern is that you can mock
IUnitOfWorkandIRepository<T>in unit tests. Take advantage of it instead of spinning up a database for every test.
When Should You Use the Repository Pattern?
The honest answer: it depends. Use the repository and Unit of Work pattern when you have complex domain logic, need strong unit-test coverage, want to isolate your business layer from a specific ORM, or anticipate swapping data sources. For a small CRUD app where EF Core alone suffices, the extra abstraction can add more friction than value. Great engineering is about applying the right tool for the job — not cargo-culting a pattern because it's popular.
Key Takeaways
You've now seen how to implement the repository pattern in C# alongside the Unit of Work pattern using EF Core, complete with runnable examples. Here's what to remember:
- The repository pattern abstracts data access behind a collection-like interface, hiding query details from your business logic.
- The Unit of Work pattern coordinates multiple repositories through a shared context so changes commit atomically.
- EF Core already implements both internally — wrap them for testability and organization, not out of habit.
- Follow best practices: keep repositories persistence-focused, return entities not
IQueryable, use async, and register the Unit of Work as scoped. - Avoid over-abstraction; apply the pattern where its benefits are real.
Master these patterns and your data access layer will stay clean, testable, and maintainable as your application grows. Ready to level up further? Explore how to combine the repository pattern with the specification pattern and CQRS to handle even the most demanding query scenarios in production C# applications.
Your go-to resource for C#, .NET, and modern software development. Follow along for daily tutorials, tips, and real-world examples.
Comments
Post a Comment