Backend Systemsbeginner
Repository Pattern in .NET
Learn the Repository pattern: abstract data access behind an interface, write testable code, and understand when the pattern adds value vs. when it's over-engineering.
Asma HafeezApril 17, 20264 min read
csharpdesign-patternsrepositorydotnetef-core
Repository Pattern
Repository abstracts data access behind an interface. Business logic calls IProductRepository.GetByIdAsync() without knowing whether that hits a database, file, or API.
Basic Repository
C#
// Interface ā what operations are available
public interface IProductRepository
{
Task<Product?> GetByIdAsync(int id);
Task<IEnumerable<Product>> GetAllAsync();
Task<IEnumerable<Product>> FindAsync(Expression<Func<Product, bool>> predicate);
Task<int> AddAsync(Product product);
Task UpdateAsync(Product product);
Task DeleteAsync(int id);
}
// EF Core implementation
public class ProductRepository(AppDbContext db) : IProductRepository
{
public async Task<Product?> GetByIdAsync(int id)
=> await db.Products.FindAsync(id);
public async Task<IEnumerable<Product>> GetAllAsync()
=> await db.Products.OrderBy(p => p.Name).ToListAsync();
public async Task<IEnumerable<Product>> FindAsync(Expression<Func<Product, bool>> predicate)
=> await db.Products.Where(predicate).ToListAsync();
public async Task<int> AddAsync(Product product)
{
db.Products.Add(product);
await db.SaveChangesAsync();
return product.Id;
}
public async Task UpdateAsync(Product product)
{
db.Products.Update(product);
await db.SaveChangesAsync();
}
public async Task DeleteAsync(int id)
{
var product = await db.Products.FindAsync(id);
if (product is not null)
{
db.Products.Remove(product);
await db.SaveChangesAsync();
}
}
}Generic Repository
C#
public interface IRepository<T> where T : class
{
Task<T?> GetByIdAsync(int id);
Task<IEnumerable<T>> GetAllAsync();
Task<int> AddAsync(T entity);
Task UpdateAsync(T entity);
Task DeleteAsync(int id);
}
public class Repository<T>(AppDbContext db) : IRepository<T> where T : class
{
protected readonly DbSet<T> _set = db.Set<T>();
public async Task<T?> GetByIdAsync(int id) => await _set.FindAsync(id);
public async Task<IEnumerable<T>> GetAllAsync() => await _set.ToListAsync();
public async Task<int> AddAsync(T entity)
{
_set.Add(entity);
await db.SaveChangesAsync();
// Return 0 ā generic doesn't know the ID property name
return 0;
}
public async Task UpdateAsync(T entity)
{
_set.Update(entity);
await db.SaveChangesAsync();
}
public async Task DeleteAsync(int id)
{
var entity = await _set.FindAsync(id);
if (entity is not null)
{
_set.Remove(entity);
await db.SaveChangesAsync();
}
}
}
// Extend with domain-specific methods
public class ProductRepository(AppDbContext db)
: Repository<Product>(db), IProductRepository
{
public async Task<IEnumerable<Product>> GetByCategoryAsync(string category)
=> await _set.Where(p => p.Category == category).ToListAsync();
public async Task<bool> IsInStockAsync(int id, int quantity)
=> await _set.AnyAsync(p => p.Id == id && p.Stock >= quantity);
}Register in DI
C#
builder.Services.AddScoped(typeof(IRepository<>), typeof(Repository<>));
builder.Services.AddScoped<IProductRepository, ProductRepository>();Test with a Mock Repository
C#
[Fact]
public async Task PlaceOrder_InStock_Succeeds()
{
// Arrange
var product = new Product { Id = 1, Name = "Widget", Stock = 10 };
var repo = Substitute.For<IProductRepository>();
repo.GetByIdAsync(1).Returns(product);
var service = new OrderService(repo, Substitute.For<IOrderRepository>());
// Act
var order = await service.PlaceOrderAsync(new PlaceOrderRequest { ProductId = 1, Quantity = 2 });
// Assert
order.Should().NotBeNull();
await repo.Received(1).GetByIdAsync(1);
}When Repository Adds Value
ā Multiple data sources (DB + cache + API)
ā Complex test suite that needs easy mocking
ā Potentially switching ORM or database
ā Domain model isolated from persistence concernsWhen Repository Is Over-Engineering
ā You'll never switch databases (99% of projects)
ā EF Core's DbSet already IS a repository
ā Small CRUD apps where the extra layer adds no clarity
ā When every repository just wraps EF Core with no added logic The Honest Take
EF Core's DbContext + DbSet<T> is already a Repository + Unit of Work pattern. Adding another layer on top often just proxies calls:
C#
// This repository adds nothing
public async Task<Product?> GetByIdAsync(int id)
=> await db.Products.FindAsync(id); // just calls EF CoreUse repositories when they add domain logic, cross-cutting concerns, or enable meaningful mocking.
Key Takeaways
- Repository abstracts data access ā business logic doesn't know it's talking to a database
- The primary benefit is testability: inject a mock repository in unit tests
- EF Core is already a repository ā don't add a layer just to have a layer
- Use domain-specific methods like
IsInStockAsyncorGetByEmailAsyncā not just generic CRUD - Generic repositories are useful as base classes; extend them with specific queries per aggregate
Enjoyed this article?
Explore the Backend Systems learning path for more.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.