
Learn the repository pattern in C# with practical examples. Master data access abstraction, EF Core integration, and best practices. Start building cleaner code today!
The repository pattern in C# is one of the most widely used design patterns for abstracting data access in .NET applications. If you've ever found your business logic tangled up with database queries, the repository pattern is the clean, testable solution you've been searching for. In this complete guide, we'll explore the repository pattern in C# with practical, runnable examples, show you how to integrate it with Entity Framework Core, and cover the best practices and common pitfalls every developer should know.
Whether you're a beginner learning how to structure your data access layer, an intermediate developer looking for best practices, or a senior engineer designing scalable architecture, this tutorial has something for you.
What Is the Repository Pattern in C#?
The repository pattern in C# is a design pattern that mediates between the domain (business logic) and data mapping layers of your application. Think of a repository as an in-memory collection of domain objects. Your application code asks the repository for objects and tells it to save objects, without knowing or caring whether the data comes from SQL Server, PostgreSQL, an API, or an in-memory list.
In short, the repository acts as an abstraction layer over your data source. This separation gives you three major benefits:
- Decoupling: Your business logic no longer depends on a specific database technology or ORM.
- Testability: You can mock the repository in unit tests, eliminating the need for a real database.
- Maintainability: Query logic lives in one place, so changes don't ripple across your codebase.
Why Use a Data Access Layer in C#?
Before diving into code, it's worth understanding why abstracting your data access layer in C# matters. Consider a controller that queries the database directly with Entity Framework:
// Tightly coupled - hard to test and maintain
public class ProductController : ControllerBase
{
private readonly AppDbContext _context;
public ProductController(AppDbContext context) => _context = context;
[HttpGet("{id}")]
public async Task<IActionResult> Get(int id)
{
var product = await _context.Products
.Where(p => p.Id == id && !p.IsDeleted)
.FirstOrDefaultAsync();
return product == null ? NotFound() : Ok(product);
}
}
This works, but the controller is now welded to AppDbContext. Testing it requires a database or an in-memory provider, and the same filtering logic (!p.IsDeleted) will be copy-pasted everywhere. This is exactly the problem the repository pattern solves.
Implementing a Basic Repository Pattern in C#
Let's build a clean repository step by step. First, define your domain entity:
public class Product
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public decimal Price { get; set; }
public bool IsDeleted { get; set; }
}
Next, create an interface that describes what the repository can do. Programming against an interface is the heart of the pattern—it's what makes your code swappable and testable.
public interface IProductRepository
{
Task<Product?> GetByIdAsync(int id);
Task<IEnumerable<Product>> GetAllAsync();
Task AddAsync(Product product);
void Update(Product product);
void Remove(Product product);
}
Now implement the interface using Entity Framework Core:
public class ProductRepository : IProductRepository
{
private readonly AppDbContext _context;
public ProductRepository(AppDbContext context) => _context = context;
public async Task<Product?> GetByIdAsync(int id) =>
await _context.Products
.FirstOrDefaultAsync(p => p.Id == id && !p.IsDeleted);
public async Task<IEnumerable<Product>> GetAllAsync() =>
await _context.Products
.Where(p => !p.IsDeleted)
.ToListAsync();
public async Task AddAsync(Product product) =>
await _context.Products.AddAsync(product);
public void Update(Product product) =>
_context.Products.Update(product);
public void Remove(Product product) =>
product.IsDeleted = true; // soft delete
}
Notice how the soft-delete filtering logic now lives in one place. Your controllers no longer need to know about IsDeleted at all:
public class ProductController : ControllerBase
{
private readonly IProductRepository _repository;
public ProductController(IProductRepository repository) => _repository = repository;
[HttpGet("{id}")]
public async Task<IActionResult> Get(int id)
{
var product = await _repository.GetByIdAsync(id);
return product == null ? NotFound() : Ok(product);
}
}
The Generic Repository Pattern in C#
If you have dozens of entities, writing a separate repository for each one becomes repetitive. The generic repository pattern in C# solves this by using generics to create a single reusable base repository.
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);
}
public class Repository<T> : IRepository<T> where T : class
{
protected readonly AppDbContext _context;
protected 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);
}
You can still create specialized repositories when an entity needs custom queries, simply by inheriting from the generic base:
public class ProductRepository : Repository<Product>, IProductRepository
{
public ProductRepository(AppDbContext context) : base(context) { }
// Custom, entity-specific query
public async Task<IEnumerable<Product>> GetExpensiveProductsAsync(decimal min) =>
await _dbSet.Where(p => p.Price >= min && !p.IsDeleted).ToListAsync();
}
Combining the Repository and Unit of Work Pattern in C#
You may have noticed none of our methods call SaveChanges(). That's intentional. The unit of work pattern in C# coordinates multiple repository operations into a single atomic transaction, ensuring data consistency. The unit of work owns the commit.
public interface IUnitOfWork : IDisposable
{
IProductRepository Products { get; }
IRepository<Category> Categories { get; }
Task<int> CompleteAsync();
}
public class UnitOfWork : IUnitOfWork
{
private readonly AppDbContext _context;
public IProductRepository Products { get; }
public IRepository<Category> Categories { get; }
public UnitOfWork(AppDbContext context)
{
_context = context;
Products = new ProductRepository(context);
Categories = new Repository<Category>(context);
}
public async Task<int> CompleteAsync() => await _context.SaveChangesAsync();
public void Dispose() => _context.Dispose();
}
Now a single transaction touching multiple entities is clean and atomic:
public async Task CreateOrderAsync(Product product, Category category)
{
await _unitOfWork.Products.AddAsync(product);
await _unitOfWork.Categories.AddAsync(category);
// Both saved together in one transaction
await _unitOfWork.CompleteAsync();
}
Registering the Repository Pattern with Dependency Injection
In .NET, wiring everything up with the built-in dependency injection container is straightforward. Add this to your Program.cs:
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseSqlServer(builder.Configuration.GetConnectionString("Default")));
builder.Services.AddScoped(typeof(IRepository<>), typeof(Repository<>));
builder.Services.AddScoped<IProductRepository, ProductRepository>();
builder.Services.AddScoped<IUnitOfWork, UnitOfWork>();
Using AddScoped ensures one repository and DbContext instance per HTTP request—the correct lifetime for web applications.
How the Repository Pattern Improves Unit Testing
One of the strongest arguments for the repository pattern in C# is testability. Because your code depends on IProductRepository rather than a concrete database, you can mock it easily with a library like Moq:
[Fact]
public async Task Get_ReturnsNotFound_WhenProductMissing()
{
var mockRepo = new Mock<IProductRepository>();
mockRepo.Setup(r => r.GetByIdAsync(99))
.ReturnsAsync((Product?)null);
var controller = new ProductController(mockRepo.Object);
var result = await controller.Get(99);
Assert.IsType<NotFoundResult>(result);
}
No database, no network, no flaky tests—just fast, deterministic unit tests.
Best Practices for the Repository Pattern in C#
- Return domain entities or DTOs, not
IQueryable. ExposingIQueryableleaks data-access concerns into your business layer and defeats the purpose of the abstraction. - Keep repositories focused on a single aggregate root. Avoid creating one giant "god repository" that handles every entity.
- Use the unit of work to control transactions, not individual repositories. This keeps commits atomic and predictable.
- Make methods
asyncall the way down. UseToListAsync,FirstOrDefaultAsync, andSaveChangesAsyncfor scalable I/O. - Inject interfaces, never concrete classes, to preserve testability and decoupling.
Common Pitfalls to Avoid
- Wrapping EF Core unnecessarily. Entity Framework Core's
DbContextandDbSetalready implement the repository and unit of work patterns. For small projects, an extra abstraction layer can be over-engineering. Use the pattern when you need testability, multiple data sources, or strict layering. - Leaky abstractions. If your repository interface returns
IQueryableor EF-specific types, you haven't truly abstracted anything. - Generic-repository overuse. A purely generic repository can't express rich, entity-specific queries well. Combine generics with specialized repositories.
- Calling SaveChanges inside every method. This breaks atomic transactions. Delegate commits to the unit of work.
Conclusion: Key Takeaways
The repository pattern in C# is a powerful tool for abstracting your data access layer, decoupling business logic from persistence concerns, and making your code dramatically easier to test and maintain. By combining a generic repository pattern in C# with the unit of work pattern and dependency injection, you get a clean, scalable architecture that adapts as your application grows.
Here are the key takeaways:
- The repository pattern abstracts data access behind interfaces, improving testability and maintainability.
- A generic repository eliminates boilerplate, while specialized repositories handle custom queries.
- The unit of work pattern coordinates atomic transactions across multiple repositories.
- Don't over-engineer—EF Core already provides a built-in repository, so apply the pattern where it adds real value.
Start applying the repository pattern in your next .NET project, and you'll write cleaner, more testable, and more professional C# code. Ready to level up? Try refactoring an existing data access layer using the examples above and see the difference for yourself!
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