Back to blog
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
Share:š•

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 concerns

When 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 Core

Use repositories when they add domain logic, cross-cutting concerns, or enable meaningful mocking.


Key Takeaways

  1. Repository abstracts data access — business logic doesn't know it's talking to a database
  2. The primary benefit is testability: inject a mock repository in unit tests
  3. EF Core is already a repository — don't add a layer just to have a layer
  4. Use domain-specific methods like IsInStockAsync or GetByEmailAsync — not just generic CRUD
  5. 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?

Share:š•

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.