Repository Pattern Deep Dive
Generic vs Specific Repositories
Read aloud is unavailable in this browser.
The Great Repository Debate
Few design decisions in the Repository pattern generate as much debate as this one: Should you use generic repositories or specific repositories?
On one side, advocates of generic repositories point to DRY principles and reduced boilerplate. On the other, proponents of specific repositories emphasize expressiveness and domain alignment. Both camps have valid arguments—and both approaches have significant tradeoffs.
This isn't an academic exercise. The choice you make affects code maintainability, testability, team velocity, and how well your persistence layer expresses your domain. Let's explore both approaches thoroughly so you can make an informed decision for your specific context.
By the end of this page, you will understand the complete picture: what generic repositories offer, what specific repositories offer, how to combine both approaches effectively, and clear guidelines for when to use each. You'll be equipped to defend your architectural choices with principled reasoning.
Generic Repositories: One Interface for All
A generic repository provides a single, parameterized interface that can work with any entity type. Instead of defining IOrderRepository, ICustomerRepository, and IProductRepository separately, you define one IRepository<T> that handles all entities.
The Generic Pattern:
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465
/// <summary>/// A generic repository interface that works with any entity type./// One interface to rule them all./// </summary>public interface IRepository<TEntity, TId> where TEntity : class, IEntity<TId>{ // Basic CRUD operations - same for every entity type TEntity? GetById(TId id); Task<TEntity?> GetByIdAsync(TId id, CancellationToken ct = default); IEnumerable<TEntity> GetAll(); Task<IEnumerable<TEntity>> GetAllAsync(CancellationToken ct = default); void Add(TEntity entity); Task AddAsync(TEntity entity, CancellationToken ct = default); void Update(TEntity entity); void Remove(TEntity entity); bool Exists(TId id); int Count(); // Generic querying with predicates IEnumerable<TEntity> Find(Expression<Func<TEntity, bool>> predicate); Task<IEnumerable<TEntity>> FindAsync( Expression<Func<TEntity, bool>> predicate, CancellationToken ct = default); TEntity? FirstOrDefault(Expression<Func<TEntity, bool>> predicate);} /// <summary>/// A single generic implementation that handles all entity types./// </summary>public class EfRepository<TEntity, TId> : IRepository<TEntity, TId> where TEntity : class, IEntity<TId>{ protected readonly DbContext _context; protected readonly DbSet<TEntity> _dbSet; public EfRepository(DbContext context) { _context = context; _dbSet = context.Set<TEntity>(); } public virtual TEntity? GetById(TId id) => _dbSet.Find(id); public virtual IEnumerable<TEntity> GetAll() => _dbSet.ToList(); public virtual void Add(TEntity entity) => _dbSet.Add(entity); public virtual void Update(TEntity entity) { _dbSet.Attach(entity); _context.Entry(entity).State = EntityState.Modified; } public virtual void Remove(TEntity entity) => _dbSet.Remove(entity); public virtual IEnumerable<TEntity> Find(Expression<Func<TEntity, bool>> predicate) => _dbSet.Where(predicate).ToList(); // ... additional implementations}Usage of Generic Repositories:
// All entity types use the same interface pattern
private readonly IRepository<Order, OrderId> _orderRepo;
private readonly IRepository<Customer, CustomerId> _customerRepo;
private readonly IRepository<Product, ProductId> _productRepo;
// Ad-hoc queries using predicates
var premiumCustomers = _customerRepo.Find(c => c.Tier == CustomerTier.Premium);
var recentOrders = _orderRepo.Find(o => o.OrderDate > DateTime.Now.AddDays(-7));
The appeal is obvious: write once, use everywhere. No need to define separate interfaces and implementations for each entity type.
The Case for Generic Repositories
Generic repositories have genuine advantages that make them attractive in certain contexts.
- •Reduced Boilerplate — One interface and one implementation instead of N interfaces and N implementations. New entities require zero repository code.
- •Consistency — All entities are accessed through identical patterns. Developers always know what methods are available.
- •Quick Prototyping — For rapid development, CRUD prototyping, or simple domains, generic repos get you working quickly.
- •DRY Principle — Common logic (caching, logging, validation) is implemented once, not repeated per entity.
- •Framework Integration — Many frameworks (Spring Data, ABP Framework) provide generic repository bases that you can use immediately.
Generic repositories work best for CRUD-heavy applications with simple domains: admin panels, content management systems, basic data entry applications. When most operations are create/read/update/delete with simple filtering, generic repos provide maximum value with minimum code.
Spring Data Example:
Spring Data JPA exemplifies the generic repository approach taken to its logical extreme:
// Just extend the interface - NO implementation needed!
public interface CustomerRepository extends JpaRepository<Customer, Long> {
// Spring Data implements this automatically!
}
public interface OrderRepository extends JpaRepository<Order, UUID> {
// All CRUD operations are available immediately
}
With zero implementation code, you get full CRUD functionality, pagination, sorting, and basic query capabilities. This is incredibly powerful for straightforward use cases.
Specific Repositories: Tailored to the Domain
A specific repository (also called a dedicated or custom repository) is an interface designed for a particular aggregate root, with methods that express the domain's needs directly.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071
/// <summary>/// A specific repository for the Order aggregate./// Every method expresses a business need, not just technical CRUD./// </summary>public interface IOrderRepository{ // Identity-based retrieval Order? GetById(OrderId id); Order GetByIdOrThrow(OrderId id); // Business-specific queries - named for what the domain needs IEnumerable<Order> FindOrdersReadyForShipment(); IEnumerable<Order> FindOrdersPendingPayment(); IEnumerable<Order> FindOrdersForCustomer(CustomerId customerId); IEnumerable<Order> FindOrdersRequiringEscalation(); // Date-based queries common in order processing IEnumerable<Order> FindOrdersPlacedBetween(DateRange dateRange); IEnumerable<Order> FindOrdersShippedInLastDays(int days); // Aggregate-specific operations void Add(Order order); void Remove(Order order); // Domain-specific existence checks bool ExistsWithIdempotencyKey(string key); bool HasPendingOrdersForCustomer(CustomerId customerId); // Generation/sequence operations often needed by aggregates OrderNumber GenerateNextOrderNumber();} /// <summary>/// A specific repository for the Customer aggregate./// Different aggregates have very different querying needs./// </summary>public interface ICustomerRepository{ Customer? GetById(CustomerId id); Customer? GetByEmail(Email email); IEnumerable<Customer> FindByName(string searchTerm); IEnumerable<Customer> FindPremiumCustomers(); IEnumerable<Customer> FindCustomersWithoutRecentOrders(int daysSinceLastOrder); IEnumerable<Customer> FindCustomersInRegion(Region region); void Add(Customer customer); void Remove(Customer customer); bool ExistsWithEmail(Email email);} /// <summary>/// A specific repository for the Inventory aggregate./// Inventory has completely different access patterns./// </summary>public interface IInventoryRepository{ InventoryItem? GetByProductId(ProductId productId); InventoryItem? GetBySkuAndWarehouse(Sku sku, WarehouseId warehouseId); IEnumerable<InventoryItem> FindItemsBelowReorderPoint(); IEnumerable<InventoryItem> FindItemsNeedingReplenishment(); IEnumerable<InventoryItem> FindItemsForWarehouse(WarehouseId warehouseId); void Save(InventoryItem item); // Inventory often needs bulk operations void ReserveStock(ProductId productId, int quantity, OrderId forOrder); void ReleaseReservation(ProductId productId, OrderId forOrder);}Notice the Differences:
Each repository interface is tailored to its aggregate's needs:
IOrderRepositoryhas methods for shipment readiness and payment statusICustomerRepositoryhas email lookup and premium customer queriesIInventoryRepositoryhas stock reservation operations
These aren't arbitrary—they reflect how the domain actually uses these aggregates. A generic Find(predicate) method couldn't express these concepts as clearly.
The Case for Specific Repositories
Specific repositories offer significant advantages, particularly in complex domains where the Repository pattern matters most.
- •Domain Expressiveness — Method names like
FindOrdersReadyForShipment()communicate intent far better thanFind(o => o.Status == 'Paid' && o.StockVerified). - •Encapsulated Query Logic — Complex queries with joins, subqueries, and business rules are encapsulated in named methods, not scattered as predicates across consumption code.
- •Optimizable — Each query method can be individually optimized.
FindOrdersReadyForShipment()might use a stored procedure, a specialized index, or a cached query—calling code doesn't know or care. - •Testable Contracts — Specific interfaces are easier to mock meaningfully. Testing that
FindPremiumCustomers()was called is clearer than testing thatFind()was called with a specific predicate. - •Controlled Surface Area — The interface exposes only what the domain needs. No
GetAll()if the domain should never load all records. NoRemove()if soft-delete is mandatory. - •Type Safety — Return types can be precise.
FindOrdersRequiringEscalation()returnsIEnumerable<Order>, notobjector a generic type that might contain anything. - •Aggregate Alignment — Different aggregates have different access patterns. Inventory needs bulk operations; Orders need status queries; Customers need search. Specific interfaces model this naturally.
Generic repositories with Find(Expression<Func<T, bool>>) leak persistence concerns into domain code. Callers must know entity properties, understand what's queryable, and construct valid predicates. This couples domain code to persistence structure. Specific repository methods hide this complexity behind intention-revealing names.
Example: The Leaky Abstraction in Action
// With generic repository - persistence details leak everywhere
var orders = _orderRepo.Find(o =>
o.Status == OrderStatus.Paid &&
o.StockVerifiedAt != null &&
o.StockVerifiedAt > DateTime.UtcNow.AddHours(-24) &&
o.ShippingAddress.Country.ShippingSupported == true &&
o.LineItems.All(li => li.StockReserved == true));
// This predicate:
// - Exposes internal status flags (StockVerifiedAt)
// - Knows about address structure
// - Understands line item stock reservation
// - Will break if any of these properties change
// - Cannot be optimized without changing all callers
// With specific repository - domain logic is hidden
var orders = _orderRepo.FindOrdersReadyForShipment();
// This method:
// - Expresses business intent clearly
// - Hides implementation details
// - Can be optimized internally without affecting callers
// - Can change criteria without breaking consumers
The specific repository keeps the domain code focused on what it needs, not how to get it.
The Problems with Pure Generic Repositories
While generic repositories offer convenience, they come with significant drawbacks that become more painful as domain complexity grows.
- •Query Logic Duplication — The same complex predicate for 'ready for shipment' might appear in 5 different places. Change the criteria? Find all 5 places. Miss one? Bug.
- •No Place for Domain Queries — Where do you put
FindOrdersRequiringEscalation()? On a service? Then the service becomes a de-facto specific repository. The pattern breaks down. - •Predicate Complexity Explosion — Real queries involve joins, subqueries, ordering, and pagination. Expression trees get unwieldy fast. Some ORM features can't be expressed in predicates at all.
- •Optimization Barriers — You can't optimize
Find(predicate)for every possible predicate. Specific methods can use raw SQL, stored procedures, or query hints when needed. - •Testing Challenges — Mocking
IRepository<T>.Find(expression)is hard. How do you verify the expression is correct? You often end up testing internal implementation details. - •Interface Bloat — As you add paging, sorting, includes, tracking options, the generic interface grows. Soon it has 20 methods, most unused by any given consumer.
- •Violates Interface Segregation — Every consumer gets every method, even ones they shouldn't use. Should anyone be able to call
GetAll()on a million-row table?
123456789101112131415161718192021222324252627282930313233343536
// Problem 1: Query duplication across the codebase// Order processing service:var readyOrders = _orderRepo.Find(o => o.Status == OrderStatus.Paid && o.StockVerified && !o.Shipped); // Shipping service (slightly different - bug!):var toShip = _orderRepo.Find(o => o.Status == OrderStatus.Paid && o.StockVerified); // Missing !o.Shipped // Dashboard service:var pending = _orderRepo.Find(o => o.Status == OrderStatus.Paid && o.StockVerified && !o.Shipped); // Duplicated // Problem 2: Complex predicates become unmanageablevar escalationOrders = _orderRepo.Find(o => o.Status != OrderStatus.Completed && o.Status != OrderStatus.Cancelled && ( (o.OrderDate < DateTime.UtcNow.AddDays(-3) && o.Status == OrderStatus.Pending) || (o.OrderDate < DateTime.UtcNow.AddDays(-1) && o.Status == OrderStatus.Processing) || (o.EscalationRequested && !o.EscalationHandled) || (o.CustomerTier == CustomerTier.Premium && o.OrderDate < DateTime.UtcNow.AddHours(-4)) ));// This is unmaintainable. Where does this live? How do you test it? // Problem 3: Features that can't be expressed as predicates// How do you specify eager loading?var orders = _orderRepo.Find(o => o.CustomerId == customerId);// Are LineItems loaded? Addresses? It depends on...what exactly? // With EF Core, you might need:var orders = _orderRepo.FindWithIncludes( o => o.CustomerId == customerId, includes: q => q.Include(o => o.LineItems).Include(o => o.ShippingAddress));// Now your "generic" interface is very EF-specific.When teams use generic repositories and find they need domain-specific queries, they often add a 'service layer' that wraps the repository with named methods. But this service layer IS a domain-specific repository by another name—just one that's poorly placed and doesn't benefit from the Repository pattern's abstractions. If you need query methods, put them in the repository.
The Hybrid Approach: Best of Both Worlds
The most pragmatic approach combines generic base functionality with specific interface definitions. You get code reuse in implementation while maintaining domain-expressive interfaces.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107
// Step 1: Define a generic base interface for common operationspublic interface IRepository<TEntity, TId> where TEntity : class{ TEntity? GetById(TId id); void Add(TEntity entity); void Remove(TEntity entity); bool Exists(TId id);} // Step 2: Define specific interfaces that extend the base// and add domain-specific methodspublic interface IOrderRepository : IRepository<Order, OrderId>{ // Domain-specific queries IEnumerable<Order> FindOrdersReadyForShipment(); IEnumerable<Order> FindOrdersPendingPayment(); IEnumerable<Order> FindForCustomer(CustomerId customerId); // Domain-specific operations OrderNumber GenerateNextOrderNumber(); bool ExistsWithIdempotencyKey(string key);} public interface ICustomerRepository : IRepository<Customer, CustomerId>{ Customer? GetByEmail(Email email); IEnumerable<Customer> FindPremiumCustomers(); IEnumerable<Customer> SearchByName(string searchTerm); bool ExistsWithEmail(Email email);} // Step 3: Create a generic base implementationpublic abstract class EfRepository<TEntity, TId> : IRepository<TEntity, TId> where TEntity : class{ protected readonly AppDbContext Context; protected readonly DbSet<TEntity> DbSet; protected EfRepository(AppDbContext context) { Context = context; DbSet = context.Set<TEntity>(); } public virtual TEntity? GetById(TId id) => DbSet.Find(id); public virtual void Add(TEntity entity) => DbSet.Add(entity); public virtual void Remove(TEntity entity) => DbSet.Remove(entity); public virtual bool Exists(TId id) => DbSet.Find(id) != null;} // Step 4: Implement specific repositories extending the basepublic class EfOrderRepository : EfRepository<Order, OrderId>, IOrderRepository{ public EfOrderRepository(AppDbContext context) : base(context) { } // Override base methods if needed public override Order? GetById(OrderId id) { // Custom loading strategy for Order aggregate return Context.Orders .Include(o => o.LineItems) .Include(o => o.ShippingAddress) .FirstOrDefault(o => o.Id == id); } // Implement domain-specific methods public IEnumerable<Order> FindOrdersReadyForShipment() { return Context.Orders .Include(o => o.LineItems) .Where(o => o.Status == OrderStatus.Paid && o.StockVerified && !o.Shipped) .OrderBy(o => o.OrderDate) .ToList(); } public IEnumerable<Order> FindOrdersPendingPayment() { return Context.Orders .Where(o => o.Status == OrderStatus.PaymentPending) .OrderBy(o => o.OrderDate) .ToList(); } public IEnumerable<Order> FindForCustomer(CustomerId customerId) { return Context.Orders .Where(o => o.CustomerId == customerId) .OrderByDescending(o => o.OrderDate) .ToList(); } public OrderNumber GenerateNextOrderNumber() { // Implementation using sequence or max+1 var maxNumber = Context.Orders .Max(o => (int?)o.OrderNumber.Value) ?? 0; return new OrderNumber(maxNumber + 1); } public bool ExistsWithIdempotencyKey(string key) { return Context.Orders.Any(o => o.IdempotencyKey == key); }}Interface: Specific (domain-aligned names, precise contracts). Implementation: Inherits from generic base (code reuse, consistency). This gives you expressive domain interfaces with minimal implementation boilerplate.
Decision Guidelines: Which Approach to Choose
The choice between generic, specific, or hybrid repositories isn't black and white. Here's a decision framework based on project characteristics.
| Factor | Favors Generic | Favors Specific |
|---|---|---|
| Domain Complexity | Simple CRUD, data-centric | Complex business rules, rich domain |
| Query Variety | Standard filters, pagination | Many domain-specific queries |
| Team Size | Small team, rapid iteration | Larger team, long-term maintenance |
| Performance Needs | Standard, uniform queries | Optimized per-query requirements |
| Testing Requirements | Basic CRUD testing | Comprehensive behavior testing |
| Code Ownership | Framework-provided adequate | Custom logic essential |
| Aggregate Complexity | Flat entities, few relations | Deep aggregates, complex loading |
- •Admin Panels / CRUD Apps → Generic repositories. Simple domains benefit from reduced boilerplate.
- •E-commerce / Financial Systems → Specific or Hybrid. Complex domains need expressive, optimizable queries.
- •Microservices with Simple Data → Generic with framework support (Spring Data). Each service is small anyway.
- •Monoliths with Rich Domains → Hybrid. Leverage base class reuse with domain-specific interfaces.
- •Domain-Driven Design Projects → Specific. DDD's emphasis on ubiquitous language demands expressive interfaces.
- •Rapid Prototyping → Generic. Get working quickly; refactor to specific when domain stabilizes.
It's perfectly valid to start with generic repositories and evolve toward specific interfaces as the domain becomes clearer. Begin simple, observe what queries you actually need, then introduce named methods for the common patterns. The repository interface is internal—you can refactor without affecting external APIs.
KEEP LEARNING
There’s more to understand.
You’re reading the introduction. Create a free account to read the complete lesson, save notes and keep track of your progress.