Loading learning content...
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.
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.
Generic repositories have genuine advantages that make them attractive in certain contexts.
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.
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:
IOrderRepository has methods for shipment readiness and payment statusICustomerRepository has email lookup and premium customer queriesIInventoryRepository has stock reservation operationsThese aren't arbitrary—they reflect how the domain actually uses these aggregates. A generic Find(predicate) method couldn't express these concepts as clearly.
Specific repositories offer significant advantages, particularly in complex domains where the Repository pattern matters most.
FindOrdersReadyForShipment() communicate intent far better than Find(o => o.Status == 'Paid' && o.StockVerified).FindOrdersReadyForShipment() might use a stored procedure, a specialized index, or a cached query—calling code doesn't know or care.FindPremiumCustomers() was called is clearer than testing that Find() was called with a specific predicate.GetAll() if the domain should never load all records. No Remove() if soft-delete is mandatory.FindOrdersRequiringEscalation() returns IEnumerable<Order>, not object or a generic type that might contain anything.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.
While generic repositories offer convenience, they come with significant drawbacks that become more painful as domain complexity grows.
FindOrdersRequiringEscalation()? On a service? Then the service becomes a de-facto specific repository. The pattern breaks down.Find(predicate) for every possible predicate. Specific methods can use raw SQL, stored procedures, or query hints when needed.IRepository<T>.Find(expression) is hard. How do you verify the expression is correct? You often end up testing internal implementation details.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 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.
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 |
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.
Beyond the generic vs. specific debate, several patterns emerge in repository design. Understanding these helps you build flexible, maintainable data access layers.
The Specification Pattern addresses query method explosion by encapsulating query criteria in objects:
// Define specifications for query criteria
public class OrdersReadyForShipmentSpec : Specification<Order>
{
public override Expression<Func<Order, bool>> ToExpression()
{
return o =>
o.Status == OrderStatus.Paid &&
o.StockVerified &&
!o.Shipped;
}
}
public class OrdersForCustomerSpec : Specification<Order>
{
private readonly CustomerId _customerId;
public OrdersForCustomerSpec(CustomerId customerId)
{
_customerId = customerId;
}
public override Expression<Func<Order, bool>> ToExpression()
{
return o => o.CustomerId == _customerId;
}
}
// Repository accepts specifications
public interface IOrderRepository
{
IEnumerable<Order> Find(Specification<Order> spec);
}
// Usage
var readyOrders = orderRepo.Find(new OrdersReadyForShipmentSpec());
var customerOrders = orderRepo.Find(
new OrdersForCustomerSpec(customerId)
.And(new OrdersReadyForShipmentSpec())) // Composable!
Specifications are composable (And, Or, Not), testable individually, and reusable across query methods.
We've thoroughly explored the spectrum from generic to specific repositories. Let's consolidate the key insights:
What's Next:
With the interface design decisions understood, we'll move to Repository Implementation in the next page. You'll learn concrete techniques for implementing repositories with ORMs like Entity Framework and Hibernate, handling complex loading strategies, managing connections, and building robust persistence layers.
You now understand the tradeoffs between generic and specific repositories, when to use each approach, and how to combine them effectively. This design decision significantly impacts your codebase's maintainability and expressiveness.