Overview
Understanding when to apply these patterns and their trade-offs is crucial. Over-abstraction can add complexity without benefits, while under-abstraction can lead to tight coupling and testing difficulties.
Core Concepts
Repository Pattern with Specific Methods
// Generic base repository
public interface IRepository<T> where T : BaseEntity
{
Task<T?> GetByIdAsync(int id);
Task<IReadOnlyList<T>> ListAllAsync();
Task<IReadOnlyList<T>> ListAsync(ISpecification<T> spec);
Task<T?> FirstOrDefaultAsync(ISpecification<T> spec);
Task<int> CountAsync(ISpecification<T> spec);
Task<T> AddAsync(T entity);
Task UpdateAsync(T entity);
Task DeleteAsync(T entity);
}
// Specific repository with domain methods
public interface IProductRepository : IRepository<Product>
{
Task<IEnumerable<Product>> GetProductsByCategoryAsync(int categoryId);
Task<IEnumerable<Product>> GetProductsInPriceRangeAsync(decimal min, decimal max);
Task<IEnumerable<Product>> GetTopSellingProductsAsync(int count);
Task<bool> IsProductNameUniqueAsync(string name, int? excludeId = null);
}
public class ProductRepository : Repository<Product>, IProductRepository
{
public ProductRepository(AppDbContext context) : base(context) { }
public async Task<IEnumerable<Product>> GetProductsByCategoryAsync(int categoryId)
{
return await _context.Products
.Where(p => p.CategoryId == categoryId)
.Include(p => p.Category)
.AsNoTracking()
.ToListAsync();
}
public async Task<IEnumerable<Product>> GetProductsInPriceRangeAsync(decimal min, decimal max)
{
return await _context.Products
.Where(p => p.Price >= min && p.Price <= max)
.OrderBy(p => p.Price)
.AsNoTracking()
.ToListAsync();
}
public async Task<IEnumerable<Product>> GetTopSellingProductsAsync(int count)
{
return await _context.Products
.OrderByDescending(p => p.TotalSales)
.Take(count)
.AsNoTracking()
.ToListAsync();
}
public async Task<bool> IsProductNameUniqueAsync(string name, int? excludeId = null)
{
var query = _context.Products.Where(p => p.Name == name);
if (excludeId.HasValue)
{
query = query.Where(p => p.Id != excludeId.Value);
}
return !await query.AnyAsync();
}
}
Unit of Work Pattern
public interface IUnitOfWork : IDisposable
{
IProductRepository Products { get; }
ICategoryRepository Categories { get; }
IOrderRepository Orders { get; }
Task<int> SaveChangesAsync(CancellationToken cancellationToken = default);
Task<bool> SaveEntitiesAsync(CancellationToken cancellationToken = default);
Task BeginTransactionAsync();
Task CommitTransactionAsync();
Task RollbackTransactionAsync();
}
public class UnitOfWork : IUnitOfWork
{
private readonly AppDbContext _context;
private IDbContextTransaction? _transaction;
public IProductRepository Products { get; }
public ICategoryRepository Categories { get; }
public IOrderRepository Orders { get; }
public UnitOfWork(
AppDbContext context,
IProductRepository productRepository,
ICategoryRepository categoryRepository,
IOrderRepository orderRepository)
{
_context = context;
Products = productRepository;
Categories = categoryRepository;
Orders = orderRepository;
}
public async Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
{
return await _context.SaveChangesAsync(cancellationToken);
}
public async Task<bool> SaveEntitiesAsync(CancellationToken cancellationToken = default)
{
try
{
await _context.SaveChangesAsync(cancellationToken);
return true;
}
catch (DbUpdateException)
{
return false;
}
}
public async Task BeginTransactionAsync()
{
_transaction = await _context.Database.BeginTransactionAsync();
}
public async Task CommitTransactionAsync()
{
try
{
await _context.SaveChangesAsync();
if (_transaction != null)
{
await _transaction.CommitAsync();
}
}
catch
{
await RollbackTransactionAsync();
throw;
}
finally
{
_transaction?.Dispose();
_transaction = null;
}
}
public async Task RollbackTransactionAsync()
{
if (_transaction != null)
{
await _transaction.RollbackAsync();
_transaction.Dispose();
_transaction = null;
}
}
public void Dispose()
{
_transaction?.Dispose();
_context.Dispose();
}
}
// Usage
public class OrderService
{
private readonly IUnitOfWork _unitOfWork;
public OrderService(IUnitOfWork unitOfWork)
{
_unitOfWork = unitOfWork;
}
public async Task<int> CreateOrderAsync(CreateOrderDto dto)
{
await _unitOfWork.BeginTransactionAsync();
try
{
// Create order
var order = new Order
{
CustomerId = dto.CustomerId,
OrderDate = DateTime.UtcNow
};
await _unitOfWork.Orders.AddAsync(order);
// Update product inventory
foreach (var item in dto.Items)
{
var product = await _unitOfWork.Products.GetByIdAsync(item.ProductId);
if (product == null)
throw new NotFoundException($"Product {item.ProductId} not found");
if (product.StockQuantity < item.Quantity)
throw new InsufficientStockException();
product.StockQuantity -= item.Quantity;
await _unitOfWork.Products.UpdateAsync(product);
}
await _unitOfWork.CommitTransactionAsync();
return order.Id;
}
catch
{
await _unitOfWork.RollbackTransactionAsync();
throw;
}
}
}
Specification Pattern
// Base specification interface
public interface ISpecification<T>
{
Expression<Func<T, bool>>? Criteria { get; }
List<Expression<Func<T, object>>> Includes { get; }
List<string> IncludeStrings { get; }
Expression<Func<T, object>>? OrderBy { get; }
Expression<Func<T, object>>? OrderByDescending { get; }
int Take { get; }
int Skip { get; }
bool IsPagingEnabled { get; }
}
// Base specification implementation
public abstract class BaseSpecification<T> : ISpecification<T>
{
public Expression<Func<T, bool>>? Criteria { get; private set; }
public List<Expression<Func<T, object>>> Includes { get; } = new();
public List<string> IncludeStrings { get; } = new();
public Expression<Func<T, object>>? OrderBy { get; private set; }
public Expression<Func<T, object>>? OrderByDescending { get; private set; }
public int Take { get; private set; }
public int Skip { get; private set; }
public bool IsPagingEnabled { get; private set; }
protected BaseSpecification() { }
protected BaseSpecification(Expression<Func<T, bool>> criteria)
{
Criteria = criteria;
}
protected void AddInclude(Expression<Func<T, object>> includeExpression)
{
Includes.Add(includeExpression);
}
protected void AddInclude(string includeString)
{
IncludeStrings.Add(includeString);
}
protected void ApplyOrderBy(Expression<Func<T, object>> orderByExpression)
{
OrderBy = orderByExpression;
}
protected void ApplyOrderByDescending(Expression<Func<T, object>> orderByDescExpression)
{
OrderByDescending = orderByDescExpression;
}
protected void ApplyPaging(int skip, int take)
{
Skip = skip;
Take = take;
IsPagingEnabled = true;
}
}
// Concrete specifications
public class ProductsByCategorySpec : BaseSpecification<Product>
{
public ProductsByCategorySpec(int categoryId)
: base(p => p.CategoryId == categoryId)
{
AddInclude(p => p.Category);
ApplyOrderBy(p => p.Name);
}
}
public class ProductsInPriceRangeSpec : BaseSpecification<Product>
{
public ProductsInPriceRangeSpec(decimal minPrice, decimal maxPrice)
: base(p => p.Price >= minPrice && p.Price <= maxPrice)
{
ApplyOrderBy(p => p.Price);
}
}
public class ActiveProductsSpec : BaseSpecification<Product>
{
public ActiveProductsSpec() : base(p => p.IsActive) { }
}
public class PagedProductsSpec : BaseSpecification<Product>
{
public PagedProductsSpec(int page, int pageSize)
{
ApplyPaging((page - 1) * pageSize, pageSize);
ApplyOrderBy(p => p.Name);
}
}
// Specification evaluator
public class SpecificationEvaluator<T> where T : class
{
public static IQueryable<T> GetQuery(IQueryable<T> inputQuery, ISpecification<T> spec)
{
var query = inputQuery;
// Apply criteria
if (spec.Criteria != null)
{
query = query.Where(spec.Criteria);
}
// Apply includes
query = spec.Includes.Aggregate(query, (current, include) => current.Include(include));
query = spec.IncludeStrings.Aggregate(query, (current, include) => current.Include(include));
// Apply ordering
if (spec.OrderBy != null)
{
query = query.OrderBy(spec.OrderBy);
}
else if (spec.OrderByDescending != null)
{
query = query.OrderByDescending(spec.OrderByDescending);
}
// Apply paging
if (spec.IsPagingEnabled)
{
query = query.Skip(spec.Skip).Take(spec.Take);
}
return query;
}
}
// Repository with specification support
public class Repository<T> : IRepository<T> where T : BaseEntity
{
protected readonly AppDbContext _context;
public Repository(AppDbContext context)
{
_context = context;
}
public async Task<IReadOnlyList<T>> ListAsync(ISpecification<T> spec)
{
return await ApplySpecification(spec).ToListAsync();
}
public async Task<T?> FirstOrDefaultAsync(ISpecification<T> spec)
{
return await ApplySpecification(spec).FirstOrDefaultAsync();
}
public async Task<int> CountAsync(ISpecification<T> spec)
{
return await ApplySpecification(spec).CountAsync();
}
private IQueryable<T> ApplySpecification(ISpecification<T> spec)
{
return SpecificationEvaluator<T>.GetQuery(_context.Set<T>().AsQueryable(), spec);
}
}
// Usage
public class ProductService
{
private readonly IRepository<Product> _repository;
public async Task<List<Product>> GetProductsByCategory(int categoryId)
{
var spec = new ProductsByCategorySpec(categoryId);
return (await _repository.ListAsync(spec)).ToList();
}
public async Task<List<Product>> GetActiveProductsInPriceRange(decimal min, decimal max)
{
// Combine specifications
var spec = new CombinedSpec(
new ActiveProductsSpec(),
new ProductsInPriceRangeSpec(min, max));
return (await _repository.ListAsync(spec)).ToList();
}
}
Bad vs Good Examples
Bad: Repository That's Just a Thin Wrapper
// Bad - no value added, just forwarding to DbContext
public interface IProductRepository
{
Task<Product?> FindAsync(int id);
Task<List<Product>> ToListAsync();
Task AddAsync(Product product);
Task<int> SaveChangesAsync();
}
// Why bad?
// - Exposes EF Core terminology (FindAsync, SaveChanges)
// - No domain-specific methods
// - Tight coupling to EF Core
// - Just adds complexity without benefits
Good: Repository With Domain Methods
// Good - domain-specific, hides implementation
public interface IProductRepository
{
Task<Product?> GetByIdAsync(int id);
Task<IEnumerable<Product>> GetAvailableProductsAsync();
Task<IEnumerable<Product>> SearchByNameAsync(string searchTerm);
Task<bool> IsSkuUniqueAsync(string sku);
Task AddAsync(Product product);
}
// Why good?
// - Domain language (not EF Core terminology)
// - Specific methods for use cases
// - Can swap implementations
// - Testable without database
Interview Tips
Tip 1: Explain that DbContext is already a Unit of Work and DbSet is a Repository. Additional abstraction is optional and depends on complexity.
Tip 2: Specifications shine when you have complex, reusable query logic. Don't overuse for simple queries.
Tip 3: Know the trade-offs: abstraction adds complexity but improves testability and flexibility.
Common Interview Questions
-
Why would you use the Repository pattern with EF Core when DbContext is already a Unit of Work?
- Testability without database, swap implementations (e.g., caching layer), hide EF Core details from business layer, domain-specific methods, consistency across different data stores. Not always necessary for simple apps.
-
What's the difference between Repository and DbSet?
- DbSet is EF Core specific, exposes IQueryable (leaky abstraction), tied to database. Repository is domain-specific, hides implementation, can add business logic, better for testing with mocks.
-
Explain the Specification pattern and when you'd use it.
- Encapsulates query logic in reusable objects. Compose specifications, test query logic independently, avoid duplicate query code. Use for complex queries, multiple filter combinations, reusable criteria. Overkill for simple CRUD.
-
How do you handle transactions with Repository and Unit of Work patterns?
- Unit of Work coordinates SaveChanges across multiple repositories, wraps in transaction if needed. DbContext already does this by default. Explicit transactions with BeginTransaction for multi-SaveChanges operations or mixing with raw SQL.
-
What are the downsides of over-abstracting data access?
- More code to maintain, harder to understand for newcomers, can obscure what's happening with database, performance implications if abstraction prevents optimization, false sense of database independence. Balance abstraction with simplicity.
-
How do you test repositories?
- Unit tests: mock DbContext or use in-memory database. Integration tests: use real database (test containers). Specification pattern allows testing query logic without database.