You’ve seen it. A massive GenericRepository<T> that tries to handle everything from User to Order. It starts clean, ends in chaos. Methods pile up, intent disappears, and your repository becomes a dumping ground for every data access pattern imaginable.

Here’s the truth: generic repositories are an over-optimization. They look elegant in tutorials but hurt intent and flexibility in real production code. After refactoring several codebases drowning in generic repository patterns, I’ve learned that a small number of specific repositories with clear methods scale far better.

This post shows you how to design repository abstractions that are specific, composable, and actually testable.

The Generic Repository Trap

Let’s start with what not to do. Here’s a typical generic repository you’ll find in half the ASP.NET Core projects out there:

public interface IRepository<T> where T : class
{
    Task<T?> GetByIdAsync(Guid id);
    Task<IEnumerable<T>> GetAllAsync();
    Task<IEnumerable<T>> FindAsync(Expression<Func<T, bool>> predicate);
    Task AddAsync(T entity);
    void Update(T entity);
    void Delete(T entity);
    Task<bool> ExistsAsync(Guid id);
}

public class GenericRepository<T> : IRepository<T> where T : class
{
    protected readonly AppDbContext _context;

    public GenericRepository(AppDbContext context) => _context = context;

    public async Task<T?> GetByIdAsync(Guid id) => 
        await _context.Set<T>().FindAsync(id);

    public async Task<IEnumerable<T>> GetAllAsync() => 
        await _context.Set<T>().ToListAsync();

    public async Task<IEnumerable<T>> FindAsync(Expression<Func<T, bool>> predicate) => 
        await _context.Set<T>().Where(predicate).ToListAsync();

    public async Task AddAsync(T entity) => 
        await _context.Set<T>().AddAsync(entity);

    public void Update(T entity) => 
        _context.Set<T>().Update(entity);

    public void Delete(T entity) => 
        _context.Set<T>().Remove(entity);

    public async Task<bool> ExistsAsync(Guid id) => 
        await _context.Set<T>().FindAsync(id) != null;
}

This looks reusable and DRY-compliant. But here’s what happens in production:

You lose intent. There’s no semantic difference between UserRepository and OrderRepository. Both expose the same generic methods, even though users and orders have completely different query patterns.

Domain-specific queries become awkward. Need to fetch orders by customer with eager-loaded line items? You end up either polluting the base class with methods like GetOrdersWithLineItemsAsync or scattering raw LINQ across your application layer.

Transaction management gets messy. Where does SaveChangesAsync live? In the base repository? Each derived class? A separate Unit of Work wrapper? The answer is usually “everywhere, inconsistently.”

Testing becomes generic and meaningless. You mock IRepository<User> and verify calls to AddAsync, but you’re not testing actual persistence behavior or your query logic.

How I Learned This the Hard Way

In a multi-tenant SaaS project, we started with a generic repository to “simplify things.” Within three months, our GenericRepository<T> had 23 methods. We had GetAllAsync, GetAllWithIncludesAsync, GetAllPagedAsync, GetAllFilteredAsync, and about five other variations.

The breaking point came when we needed tenant-specific query filters. Suddenly, every repository method needed a tenantId parameter, but only for certain entities. Our “simple” abstraction became a minefield of nullable parameters and conditional logic.

The refactor to specific repositories took two days. The clarity gain was immediate.

Make Repositories Aggregate-Specific

Each aggregate root deserves its own repository interface. This isn’t just DDD orthodoxy, it’s practical architecture.

Here’s the better approach:

public interface IUserRepository
{
    Task<User?> GetByIdAsync(Guid id);
    Task<User?> GetByEmailAsync(string email);
    Task<IEnumerable<User>> GetActiveUsersAsync();
    Task AddAsync(User user);
    Task SaveChangesAsync();
}

public interface IOrderRepository
{
    Task<Order?> GetByIdAsync(Guid id);
    Task<Order?> GetByOrderNumberAsync(string orderNumber);
    Task<IEnumerable<Order>> GetRecentOrdersAsync(Guid customerId, int limit);
    Task<IEnumerable<Order>> GetPendingOrdersAsync();
    Task AddAsync(Order order);
    Task SaveChangesAsync();
}

Notice what changed:

Clear responsibilities. Each repository exposes only the queries its aggregate needs. No GetAllAsync dumping thousands of records. No FindAsync with magic expressions.

Explicit domain language. GetByEmailAsync reads better than FindAsync(u => u.Email == email) scattered across your application layer.

Easier testing. You mock IUserRepository with its five specific methods, not a generic interface with 20+ methods where only two are used.

Implementation Using EF Core

Here’s how to implement these repositories cleanly:

public class UserRepository : IUserRepository
{
    private readonly AppDbContext _context;

    public UserRepository(AppDbContext context) => _context = context;

    public async Task<User?> GetByIdAsync(Guid id) =>
        await _context.Users
            .AsNoTracking()
            .FirstOrDefaultAsync(u => u.Id == id);

    public async Task<User?> GetByEmailAsync(string email) =>
        await _context.Users
            .FirstOrDefaultAsync(u => u.Email == email);

    public async Task<IEnumerable<User>> GetActiveUsersAsync() =>
        await _context.Users
            .Where(u => u.IsActive)
            .OrderBy(u => u.Email)
            .ToListAsync();

    public async Task AddAsync(User user) => 
        await _context.Users.AddAsync(user);

    public Task SaveChangesAsync() => 
        _context.SaveChangesAsync();
}
public class OrderRepository : IOrderRepository
{
    private readonly AppDbContext _context;

    public OrderRepository(AppDbContext context) => _context = context;

    public async Task<Order?> GetByIdAsync(Guid id) =>
        await _context.Orders
            .Include(o => o.LineItems)
            .FirstOrDefaultAsync(o => o.Id == id);

    public async Task<Order?> GetByOrderNumberAsync(string orderNumber) =>
        await _context.Orders
            .Include(o => o.LineItems)
            .FirstOrDefaultAsync(o => o.OrderNumber == orderNumber);

    public async Task<IEnumerable<Order>> GetRecentOrdersAsync(Guid customerId, int limit) =>
        await _context.Orders
            .Where(o => o.CustomerId == customerId)
            .OrderByDescending(o => o.CreatedAt)
            .Take(limit)
            .ToListAsync();

    public async Task<IEnumerable<Order>> GetPendingOrdersAsync() =>
        await _context.Orders
            .Where(o => o.Status == OrderStatus.Pending)
            .Include(o => o.LineItems)
            .ToListAsync();

    public async Task AddAsync(Order order) => 
        await _context.Orders.AddAsync(order);

    public Task SaveChangesAsync() => 
        _context.SaveChangesAsync();
}

Key benefits:

Unit of Work is handled naturally by DbContext. You don’t need a separate UoW wrapper. EF Core already tracks changes and batches updates.

No unnecessary inheritance. Each repository stands alone with its dependencies clearly stated.

Easy extension for caching or domain events. Wrap specific methods with decorators if needed. You’re not fighting a base class implementation.

Include logic lives where it belongs. Orders need line items? Include them in the repository. Users don’t need includes? Don’t add them.

Here’s how your folder structure should look:

src/
 ├── Domain/
 │    └── Entities/
 │         ├── User.cs
 │         └── Order.cs
 ├── Application/
 │    └── Repositories/
 │         ├── IUserRepository.cs
 │         └── IOrderRepository.cs
 ├── Infrastructure/
 │    └── Persistence/
 │         ├── Repositories/
 │         │    ├── UserRepository.cs
 │         │    └── OrderRepository.cs
 │         └── AppDbContext.cs

Register them in Program.cs:

builder.Services.AddScoped<IUserRepository, UserRepository>();
builder.Services.AddScoped<IOrderRepository, OrderRepository>();

When You Actually Need Shared Behavior

Sometimes you have legitimate duplication. Multiple repositories need ExistsAsync or soft delete logic. Don’t reach for a base class. Use composition.

Here’s a helper for shared query patterns:

public static class RepositoryExtensions
{
    public static async Task<bool> ExistsAsync<T>(
        this DbSet<T> set, 
        Expression<Func<T, bool>> predicate) where T : class
        => await set.AnyAsync(predicate);

    public static IQueryable<T> WhereActive<T>(
        this DbSet<T> set) where T : class, ISoftDeletable
        => set.Where(e => !e.IsDeleted);
}

Use it like this:

public async Task<bool> UserExistsAsync(string email) =>
    await _context.Users.ExistsAsync(u => u.Email == email);

public async Task<IEnumerable<User>> GetActiveUsersAsync() =>
    await _context.Users.WhereActive().ToListAsync();

If you absolutely need a base class (say, for consistent audit field updates), keep it minimal:

public abstract class BaseRepository
{
    protected readonly AppDbContext _context;

    protected BaseRepository(AppDbContext context) => _context = context;

    protected void SetAuditFields<T>(T entity) where T : IAuditable
    {
        var now = DateTime.UtcNow;
        if (entity.CreatedAt == default) entity.CreatedAt = now;
        entity.UpdatedAt = now;
    }
}

public class UserRepository : BaseRepository, IUserRepository
{
    public UserRepository(AppDbContext context) : base(context) { }

    public async Task AddAsync(User user)
    {
        SetAuditFields(user);
        await _context.Users.AddAsync(user);
    }

    // other methods...
}

But be honest with yourself. If you’re only calling SetAuditFields in two places, just duplicate those two lines. Clarity beats DRY.

Testing Repositories the Right Way

Don’t mock DbSet. It’s painful, brittle, and doesn’t test actual persistence behavior. Use integration tests with a real database.

Here’s a test using EF Core’s in-memory provider:

public class UserRepositoryTests : IDisposable
{
    private readonly AppDbContext _context;
    private readonly IUserRepository _repository;

    public UserRepositoryTests()
    {
        var options = new DbContextOptionsBuilder<AppDbContext>()
            .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
            .Options;

        _context = new AppDbContext(options);
        _repository = new UserRepository(_context);
    }

    [Fact]
    public async Task AddAsync_Should_Persist_User()
    {
        var user = new User("test@bytecrafted.dev") { IsActive = true };
        
        await _repository.AddAsync(user);
        await _repository.SaveChangesAsync();

        var loaded = await _repository.GetByEmailAsync("test@bytecrafted.dev");
        
        Assert.NotNull(loaded);
        Assert.Equal("test@bytecrafted.dev", loaded.Email);
        Assert.True(loaded.IsActive);
    }

    [Fact]
    public async Task GetActiveUsersAsync_Should_Return_Only_Active()
    {
        await _repository.AddAsync(new User("active@test.com") { IsActive = true });
        await _repository.AddAsync(new User("inactive@test.com") { IsActive = false });
        await _repository.SaveChangesAsync();

        var activeUsers = await _repository.GetActiveUsersAsync();

        Assert.Single(activeUsers);
        Assert.Equal("active@test.com", activeUsers.First().Email);
    }

    public void Dispose() => _context.Dispose();
}

For more complex scenarios, use SQLite in-memory mode or a test container with SQL Server. This catches mapping bugs, migration issues, and query performance problems that mocks hide.

Notice we’re not mocking DbSet. Real persistence tests catch configuration bugs early.

When to Skip the Repository Pattern Entirely

Here’s the controversial take: for small CRUD apps or simple query scenarios, skip repositories altogether.

If your application layer looks like this:

public class GetUserQueryHandler
{
    private readonly IUserRepository _repository;

    public async Task<UserDto> Handle(GetUserQuery query) =>
        await _repository.GetByIdAsync(query.UserId);
}

You’ve just added a pointless layer. Use DbContext directly:

public class GetUserQueryHandler
{
    private readonly AppDbContext _context;

    public async Task<UserDto> Handle(GetUserQuery query) =>
        await _context.Users
            .AsNoTracking()
            .Where(u => u.Id == query.UserId)
            .Select(u => new UserDto { /* projection */ })
            .FirstOrDefaultAsync();
}

Abstractions should protect domain logic, not just hide EF Core. If you’re building a straightforward REST API with simple queries, repositories add ceremony without value.

Use repositories when:

  • You have complex domain logic that needs isolation from infrastructure
  • You need multiple data sources (EF Core + Dapper + external API)
  • You’re doing event sourcing or CQRS where write models differ from read models
  • Your aggregate roots have rich behavior that deserves a cohesive data access layer

Don’t use repositories when:

  • You’re building simple CRUD endpoints
  • Your entities are anemic (just properties, no behavior)
  • You’re using CQRS with separate read models (repositories on the write side only)
  • You’re adding them “because that’s what you do”

For more on separating read and write concerns, see my upcoming post on implementing CQRS in ASP.NET Core without MediatR.

Project Structure with Specific Repositories

Here’s how the layers connect in a clean architecture setup:

graph TB
    A[Application Layer<br/>Use Cases + Interfaces] --> B[Domain Layer<br/>Entities + Business Logic]
    C[Infrastructure Layer<br/>EF Core + Repositories] --> A
    C --> B
    D[API Layer<br/>Controllers + DTOs] --> A
    
    style A fill:#e1f5ff
    style B fill:#fff3e0
    style C fill:#f3e5f5
    style D fill:#e8f5e9
    

The repository interface lives in Application (or Domain if you prefer DDD strict layering). The implementation lives in Infrastructure. Your API layer depends only on Application, never directly on Infrastructure.

This gives you flexibility. Swap EF Core for Dapper? Change the Infrastructure layer. Your application logic doesn’t care.

My Take on Repository Patterns

Generic repositories are a premature optimization. They look elegant in conference slides but cause friction in real projects.

A few specific repositories with clear, intention-revealing methods beat one massive generic repository every time. You gain:

  • Readability. GetByEmailAsync is clearer than FindAsync(u => u.Email == email) scattered everywhere.
  • Flexibility. Each repository evolves independently based on its aggregate’s needs.
  • Testability. You mock focused interfaces, not sprawling generic contracts.
  • Maintainability. New developers understand IOrderRepository immediately. They spend hours deciphering a 30-method IRepository<T>.

If you’re drowning in generic repository hell, start small. Pick your most painful aggregate. Extract a specific interface. Implement it cleanly. Measure the difference in code clarity. Then refactor the rest.

Your future self will thank you.

References

  1. DbContext Lifetime, Configuration, and Initialization - Microsoft Docs
  2. Implementing the Repository and Unit of Work Patterns in an ASP.NET MVC Application - Microsoft Docs
  3. Domain-Driven Design: Tackling Complexity in the Heart of Software - Eric Evans
  4. Testing EF Core Applications - Microsoft Docs
  5. The Repository Pattern - Martin Fowler

Related Posts