![Master-Repositor]()
Previous article: CQRS and MediatR Masterclass: Solve Business Complexity in ASP.NET Core (Part-36 of 40)
📋 Table of Contents
Introduction to Data Access Patterns
Why We Need These Patterns
Repository Pattern Deep Dive
Unit of Work Pattern Explained
Real-World E-Commerce Implementation
Advanced Implementation Scenarios
Testing Strategies
Performance Optimization
Common Pitfalls & Best Practices
Alternatives & When to Use What
1. Introduction to Data Access Patterns
The Problem with Direct Data Access
Imagine you're building an e-commerce application. Without proper patterns, your controllers might look like this:
// ❌ DON'T: Direct data access in controllerspublic class ProductController : Controller{
private readonly ApplicationDbContext _context;
public ProductController(ApplicationDbContext context)
{
_context = context;
}
public IActionResult GetProducts()
{
var products = _context.Products
.Include(p => p.Category)
.Include(p => p.Inventory)
.Where(p => p.IsActive)
.ToList();
return View(products);
}
public IActionResult CreateProduct(Product product)
{
_context.Products.Add(product);
_context.SaveChanges(); // What if this fails?
return RedirectToAction("GetProducts");
}}
Problems with this approach:
Tight coupling between controllers and Entity Framework
Difficult to test - requires mocking DbContext
Code duplication across multiple controllers
No abstraction - changing data access technology requires massive refactoring
Transaction management is manual and error-prone
The Solution: Repository & Unit of Work Patterns
// ✅ DO: Clean, testable controllerspublic class ProductController : Controller{
private readonly IUnitOfWork _unitOfWork;
public ProductController(IUnitOfWork unitOfWork)
{
_unitOfWork = unitOfWork;
}
public async Task<IActionResult> GetProducts()
{
var products = await _unitOfWork.Products
.GetActiveProductsWithDetailsAsync();
return View(products);
}
public async Task<IActionResult> CreateProduct(Product product)
{
_unitOfWork.Products.Add(product);
await _unitOfWork.CommitAsync(); // Atomic operation
return RedirectToAction("GetProducts");
}}
2. Why We Need These Patterns
Real-World Scenario: E-Commerce Platform
Consider an e-commerce order processing system:
// ❌ Problematic approach without patternspublic async Task ProcessOrder(Order order){
// This method has too many responsibilities
using var transaction = await _context.Database.BeginTransactionAsync();
try
{
// 1. Update inventory
foreach (var item in order.Items)
{
var product = await _context.Products.FindAsync(item.ProductId);
product.StockQuantity -= item.Quantity;
if (product.StockQuantity < 0)
throw new Exception("Insufficient stock");
}
// 2. Create order
_context.Orders.Add(order);
// 3. Process payment
var payment = new Payment
{
OrderId = order.Id,
Amount = order.TotalAmount
};
_context.Payments.Add(payment);
// 4. Send notification
var customer = await _context.Customers.FindAsync(order.CustomerId);
await _emailService.SendOrderConfirmation(customer.Email, order);
await _context.SaveChangesAsync();
await transaction.CommitAsync();
}
catch
{
await transaction.RollbackAsync();
throw;
}}
Benefits of Using Patterns
Testability: Mock repositories instead of complex DbContext
Maintainability: Centralize data access logic
Flexibility: Switch data providers easily
Consistency: Enforce business rules uniformly
Performance: Implement caching and optimization centrally
3. Repository Pattern Deep Dive
Core Repository Interface
// Core/Interfaces/IRepository.cspublic interface IRepository<T> where T : BaseEntity{
Task<T> GetByIdAsync(int id);
Task<IReadOnlyList<T>> GetAllAsync();
Task<T> AddAsync(T entity);
Task UpdateAsync(T entity);
Task DeleteAsync(T entity);
Task<bool> ExistsAsync(int id);
Task<int> CountAsync();
Task<IReadOnlyList<T>> GetPagedAsync(int pageNumber, int pageSize);}
Generic Repository Implementation
// Infrastructure/Data/Repository.cspublic class Repository<T> : IRepository<T> where T : BaseEntity{
protected readonly ApplicationDbContext _context;
protected readonly DbSet<T> _dbSet;
public Repository(ApplicationDbContext context)
{
_context = context;
_dbSet = context.Set<T>();
}
public virtual async Task<T> GetByIdAsync(int id)
{
return await _dbSet.FindAsync(id);
}
public virtual async Task<IReadOnlyList<T>> GetAllAsync()
{
return await _dbSet.ToListAsync();
}
public virtual async Task<T> AddAsync(T entity)
{
await _dbSet.AddAsync(entity);
return entity;
}
public virtual async Task UpdateAsync(T entity)
{
_context.Entry(entity).State = EntityState.Modified;
await Task.CompletedTask;
}
public virtual async Task DeleteAsync(T entity)
{
_dbSet.Remove(entity);
await Task.CompletedTask;
}
public virtual async Task<bool> ExistsAsync(int id)
{
return await _dbSet.AnyAsync(e => e.Id == id);
}
public virtual async Task<int> CountAsync()
{
return await _dbSet.CountAsync();
}
public virtual async Task<IReadOnlyList<T>> GetPagedAsync(int pageNumber, int pageSize)
{
return await _dbSet
.Skip((pageNumber - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
}}
Specialized Repository Interfaces
// Core/Interfaces/IProductRepository.cspublic interface IProductRepository : IRepository<Product>{
Task<Product> GetProductWithCategoryAsync(int productId);
Task<IReadOnlyList<Product>> GetProductsByCategoryAsync(int categoryId);
Task<IReadOnlyList<Product>> SearchProductsAsync(string searchTerm, int pageNumber, int pageSize);
Task<IReadOnlyList<Product>> GetActiveProductsWithDetailsAsync();
Task UpdateProductStockAsync(int productId, int quantityChange);
Task<bool> IsProductNameUnique(string productName, int? excludeProductId = null);}
// Core/Interfaces/IOrderRepository.cspublic interface IOrderRepository : IRepository<Order>{
Task<Order> GetOrderWithDetailsAsync(int orderId);
Task<IReadOnlyList<Order>> GetOrdersByCustomerAsync(int customerId);
Task<IReadOnlyList<Order>> GetPendingOrdersAsync();
Task<Order> CreateOrderFromCartAsync(int cartId, string customerId);
Task UpdateOrderStatusAsync(int orderId, OrderStatus status);}
Specialized Repository Implementations
// Infrastructure/Data/Repositories/ProductRepository.cspublic class ProductRepository : Repository<Product>, IProductRepository{
public ProductRepository(ApplicationDbContext context) : base(context)
{
}
public async Task<Product> GetProductWithCategoryAsync(int productId)
{
return await _context.Products
.Include(p => p.Category)
.Include(p => p.Inventory)
.FirstOrDefaultAsync(p => p.Id == productId);
}
public async Task<IReadOnlyList<Product>> GetProductsByCategoryAsync(int categoryId)
{
return await _context.Products
.Where(p => p.CategoryId == categoryId && p.IsActive)
.Include(p => p.Category)
.OrderBy(p => p.Name)
.ToListAsync();
}
public async Task<IReadOnlyList<Product>> SearchProductsAsync(string searchTerm, int pageNumber, int pageSize)
{
return await _context.Products
.Where(p => p.IsActive &&
(p.Name.Contains(searchTerm) ||
p.Description.Contains(searchTerm) ||
p.Category.Name.Contains(searchTerm)))
.Include(p => p.Category)
.OrderBy(p => p.Name)
.Skip((pageNumber - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
}
public async Task<IReadOnlyList<Product>> GetActiveProductsWithDetailsAsync()
{
return await _context.Products
.Where(p => p.IsActive)
.Include(p => p.Category)
.Include(p => p.Inventory)
.Include(p => p.Reviews)
.OrderBy(p => p.Category.Name)
.ThenBy(p => p.Name)
.ToListAsync();
}
public async Task UpdateProductStockAsync(int productId, int quantityChange)
{
var product = await _context.Products.FindAsync(productId);
if (product != null)
{
product.StockQuantity += quantityChange;
if (product.StockQuantity < 0)
throw new InvalidOperationException("Insufficient stock");
product.LastStockUpdate = DateTime.UtcNow;
}
}
public async Task<bool> IsProductNameUnique(string productName, int? excludeProductId = null)
{
return !await _context.Products
.AnyAsync(p => p.Name == productName &&
p.Id != excludeProductId);
}}
4. Unit of Work Pattern Explained
Unit of Work Interface
// Core/Interfaces/IUnitOfWork.cspublic interface IUnitOfWork : IDisposable{
// Generic repositories
IRepository<T> Repository<T>() where T : BaseEntity;
// Specific repositories
IProductRepository Products { get; }
IOrderRepository Orders { get; }
ICustomerRepository Customers { get; }
ICategoryRepository Categories { get; }
IPaymentRepository Payments { get; }
// Transaction management
Task<int> CommitAsync();
Task RollbackAsync();
Task BeginTransactionAsync();
Task CommitTransactionAsync();
// Change tracking
void DetachAllEntities();
void ClearChangeTracker();}
Unit of Work Implementation
// Infrastructure/Data/UnitOfWork.cspublic class UnitOfWork : IUnitOfWork{
private readonly ApplicationDbContext _context;
private readonly ILogger<UnitOfWork> _logger;
private IDbContextTransaction _transaction;
private bool _disposed = false;
// Specific repositories
public IProductRepository Products { get; }
public IOrderRepository Orders { get; }
public ICustomerRepository Customers { get; }
public ICategoryRepository Categories { get; }
public IPaymentRepository Payments { get; }
// Repository cache
private Dictionary<Type, object> _repositories;
public UnitOfWork(ApplicationDbContext context, ILogger<UnitOfWork> logger)
{
_context = context;
_logger = logger;
// Initialize specific repositories
Products = new ProductRepository(_context);
Orders = new OrderRepository(_context);
Customers = new CustomerRepository(_context);
Categories = new CategoryRepository(_context);
Payments = new PaymentRepository(_context);
_repositories = new Dictionary<Type, object>();
}
public IRepository<T> Repository<T>() where T : BaseEntity
{
var type = typeof(T);
if (!_repositories.ContainsKey(type))
{
_repositories[type] = new Repository<T>(_context);
}
return (IRepository<T>)_repositories[type];
}
public async Task<int> CommitAsync()
{
try
{
// Audit trail - automatically set modified dates
var entries = _context.ChangeTracker.Entries<BaseEntity>()
.Where(e => e.State == EntityState.Added || e.State == EntityState.Modified);
foreach (var entry in entries)
{
if (entry.State == EntityState.Added)
{
entry.Entity.CreatedAt = DateTime.UtcNow;
entry.Entity.CreatedBy = "system"; // Get from current user in real scenario
}
entry.Entity.UpdatedAt = DateTime.UtcNow;
entry.Entity.UpdatedBy = "system"; // Get from current user in real scenario
}
return await _context.SaveChangesAsync();
}
catch (DbUpdateException ex)
{
_logger.LogError(ex, "Database update error occurred");
throw new RepositoryException("An error occurred while saving changes to the database", ex);
}
catch (Exception ex)
{
_logger.LogError(ex, "Unexpected error occurred during commit");
throw;
}
}
public async Task BeginTransactionAsync()
{
if (_transaction != null)
{
throw new InvalidOperationException("A transaction is already in progress");
}
_transaction = await _context.Database.BeginTransactionAsync();
_logger.LogInformation("Database transaction started");
}
public async Task CommitTransactionAsync()
{
if (_transaction == null)
{
throw new InvalidOperationException("No transaction to commit");
}
try
{
await _transaction.CommitAsync();
_logger.LogInformation("Database transaction committed");
}
catch (Exception ex)
{
_logger.LogError(ex, "Error committing transaction");
await _transaction.RollbackAsync();
throw;
}
finally
{
_transaction.Dispose();
_transaction = null;
}
}
public async Task RollbackAsync()
{
if (_transaction != null)
{
await _transaction.RollbackAsync();
_transaction.Dispose();
_transaction = null;
_logger.LogInformation("Database transaction rolled back");
}
// Clear change tracker to prevent stale data
ClearChangeTracker();
}
public void DetachAllEntities()
{
var entries = _context.ChangeTracker.Entries()
.Where(e => e.State != EntityState.Detached)
.ToList();
foreach (var entry in entries)
{
entry.State = EntityState.Detached;
}
}
public void ClearChangeTracker()
{
_context.ChangeTracker.Clear();
}
protected virtual void Dispose(bool disposing)
{
if (!_disposed && disposing)
{
_transaction?.Dispose();
_context?.Dispose();
}
_disposed = true;
}
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}}
5. Real-World E-Commerce Implementation
Complete Domain Models
// Core/Entities/BaseEntity.cspublic abstract class BaseEntity{
public int Id { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
public string CreatedBy { get; set; } = "system";
public string UpdatedBy { get; set; } = "system";
public bool IsActive { get; set; } = true;}
// Core/Entities/Product.cspublic class Product : BaseEntity{
public string Name { get; set; }
public string Description { get; set; }
public decimal Price { get; set; }
public decimal? DiscountPrice { get; set; }
public int StockQuantity { get; set; }
public string SKU { get; set; }
public string ImageUrl { get; set; }
// Relationships
public int CategoryId { get; set; }
public Category Category { get; set; }
public ICollection<OrderItem> OrderItems { get; set; }
public ICollection<Review> Reviews { get; set; }
public Inventory Inventory { get; set; }
// Computed properties
public decimal CurrentPrice => DiscountPrice ?? Price;
public bool InStock => StockQuantity > 0;
public double AverageRating => Reviews?.Any() == true ?
Reviews.Average(r => r.Rating) : 0;}
// Core/Entities/Order.cspublic class Order : BaseEntity{
public string OrderNumber { get; set; } = GenerateOrderNumber();
public DateTime OrderDate { get; set; } = DateTime.UtcNow;
public decimal TotalAmount { get; set; }
public OrderStatus Status { get; set; } = OrderStatus.Pending;
// Customer information
public int CustomerId { get; set; }
public Customer Customer { get; set; }
// Shipping information
public string ShippingAddress { get; set; }
public string ShippingCity { get; set; }
public string ShippingZipCode { get; set; }
// Navigation properties
public ICollection<OrderItem> OrderItems { get; set; }
public Payment Payment { get; set; }
private static string GenerateOrderNumber()
{
return $"ORD-{DateTime.UtcNow:yyyyMMdd-HHmmss}-{Guid.NewGuid().ToString()[..8].ToUpper()}";
}}
// Core/Enums/OrderStatus.cspublic enum OrderStatus{
Pending,
Confirmed,
Processing,
Shipped,
Delivered,
Cancelled,
Refunded
}
Advanced Service Layer
// Core/Interfaces/IOrderService.cspublic interface IOrderService{
Task<OrderResult> CreateOrderAsync(CreateOrderRequest request);
Task<Order> ProcessOrderAsync(int orderId);
Task CancelOrderAsync(int orderId);
Task<Order> GetOrderWithDetailsAsync(int orderId);
Task<IEnumerable<Order>> GetCustomerOrdersAsync(int customerId);
Task UpdateOrderStatusAsync(int orderId, OrderStatus status);}
// Infrastructure/Services/OrderService.cspublic class OrderService : IOrderService{
private readonly IUnitOfWork _unitOfWork;
private readonly IEmailService _emailService;
private readonly IPaymentService _paymentService;
private readonly ILogger<OrderService> _logger;
public OrderService(
IUnitOfWork unitOfWork,
IEmailService emailService,
IPaymentService paymentService,
ILogger<OrderService> logger)
{
_unitOfWork = unitOfWork;
_emailService = emailService;
_paymentService = paymentService;
_logger = logger;
}
public async Task<OrderResult> CreateOrderAsync(CreateOrderRequest request)
{
await _unitOfWork.BeginTransactionAsync();
try
{
// 1. Validate customer
var customer = await _unitOfWork.Customers.GetByIdAsync(request.CustomerId);
if (customer == null)
throw new ArgumentException("Customer not found");
// 2. Create order
var order = new Order
{
CustomerId = request.CustomerId,
ShippingAddress = request.ShippingAddress,
ShippingCity = request.ShippingCity,
ShippingZipCode = request.ShippingZipCode,
OrderItems = new List<OrderItem>()
};
decimal totalAmount = 0;
// 3. Process order items and update inventory
foreach (var item in request.Items)
{
var product = await _unitOfWork.Products.GetByIdAsync(item.ProductId);
if (product == null)
throw new ArgumentException($"Product {item.ProductId} not found");
if (product.StockQuantity < item.Quantity)
throw new InvalidOperationException($"Insufficient stock for product {product.Name}");
// Update stock
await _unitOfWork.Products.UpdateProductStockAsync(product.Id, -item.Quantity);
// Create order item
var orderItem = new OrderItem
{
ProductId = product.Id,
Quantity = item.Quantity,
UnitPrice = product.CurrentPrice,
TotalPrice = product.CurrentPrice * item.Quantity
};
order.OrderItems.Add(orderItem);
totalAmount += orderItem.TotalPrice;
}
order.TotalAmount = totalAmount;
await _unitOfWork.Orders.AddAsync(order);
// 4. Process payment
var paymentResult = await _paymentService.ProcessPaymentAsync(new PaymentRequest
{
OrderId = order.Id,
Amount = totalAmount,
PaymentMethod = request.PaymentMethod
});
if (!paymentResult.Success)
throw new InvalidOperationException($"Payment failed: {paymentResult.ErrorMessage}");
// 5. Save all changes
await _unitOfWork.CommitAsync();
await _unitOfWork.CommitTransactionAsync();
// 6. Send confirmation email
await _emailService.SendOrderConfirmationAsync(customer.Email, order);
_logger.LogInformation("Order {OrderId} created successfully for customer {CustomerId}",
order.Id, customer.Id);
return new OrderResult
{
Success = true,
OrderId = order.Id,
OrderNumber = order.OrderNumber,
TotalAmount = totalAmount
};
}
catch (Exception ex)
{
await _unitOfWork.RollbackAsync();
_logger.LogError(ex, "Error creating order for customer {CustomerId}", request.CustomerId);
return new OrderResult
{
Success = false,
ErrorMessage = ex.Message
};
}
}
public async Task<Order> ProcessOrderAsync(int orderId)
{
var order = await _unitOfWork.Orders.GetOrderWithDetailsAsync(orderId);
if (order == null)
throw new ArgumentException("Order not found");
if (order.Status != OrderStatus.Pending)
throw new InvalidOperationException("Order is not in pending status");
// Validate stock availability
foreach (var item in order.OrderItems)
{
if (item.Product.StockQuantity < item.Quantity)
{
order.Status = OrderStatus.Cancelled;
await _unitOfWork.CommitAsync();
throw new InvalidOperationException(
$"Insufficient stock for product {item.Product.Name}");
}
}
order.Status = OrderStatus.Confirmed;
await _unitOfWork.CommitAsync();
_logger.LogInformation("Order {OrderId} processed successfully", orderId);
return order;
}}
Advanced Controller Implementation
// API/Controllers/OrdersController.cs[ApiController][Route("api/[controller]")][Authorize]public class OrdersController : ControllerBase{
private readonly IOrderService _orderService;
private readonly IUnitOfWork _unitOfWork;
private readonly IMapper _mapper;
public OrdersController(
IOrderService orderService,
IUnitOfWork unitOfWork,
IMapper mapper)
{
_orderService = orderService;
_unitOfWork = unitOfWork;
_mapper = mapper;
}
[HttpPost]
public async Task<ActionResult<OrderResponse>> CreateOrder(CreateOrderRequest request)
{
try
{
var result = await _orderService.CreateOrderAsync(request);
if (!result.Success)
return BadRequest(new { error = result.ErrorMessage });
var order = await _unitOfWork.Orders.GetOrderWithDetailsAsync(result.OrderId);
var response = _mapper.Map<OrderResponse>(order);
return CreatedAtAction(nameof(GetOrder), new { id = result.OrderId }, response);
}
catch (Exception ex)
{
return StatusCode(500, new { error = "An error occurred while creating the order" });
}
}
[HttpGet("{id}")]
public async Task<ActionResult<OrderResponse>> GetOrder(int id)
{
var order = await _unitOfWork.Orders.GetOrderWithDetailsAsync(id);
if (order == null)
return NotFound();
var response = _mapper.Map<OrderResponse>(order);
return Ok(response);
}
[HttpGet("customer/{customerId}")]
public async Task<ActionResult<IEnumerable<OrderResponse>>> GetCustomerOrders(int customerId)
{
var orders = await _unitOfWork.Orders.GetOrdersByCustomerAsync(customerId);
var response = _mapper.Map<List<OrderResponse>>(orders);
return Ok(response);
}
[HttpPut("{id}/status")]
[Authorize(Roles = "Admin,Manager")]
public async Task<IActionResult> UpdateOrderStatus(int id, UpdateOrderStatusRequest request)
{
try
{
await _orderService.UpdateOrderStatusAsync(id, request.Status);
return NoContent();
}
catch (ArgumentException ex)
{
return NotFound(new { error = ex.Message });
}
catch (InvalidOperationException ex)
{
return BadRequest(new { error = ex.Message });
}
}}
6. Advanced Implementation Scenarios
Caching Repository Decorator
// Infrastructure/Data/Decorators/CachedProductRepository.cspublic class CachedProductRepository : IProductRepository{
private readonly IProductRepository _decorated;
private readonly IDistributedCache _cache;
private readonly ILogger<CachedProductRepository> _logger;
public CachedProductRepository(
IProductRepository decorated,
IDistributedCache cache,
ILogger<CachedProductRepository> logger)
{
_decorated = decorated;
_cache = cache;
_logger = logger;
}
public async Task<Product> GetByIdAsync(int id)
{
var cacheKey = $"product_{id}";
try
{
var cachedProduct = await _cache.GetStringAsync(cacheKey);
if (cachedProduct != null)
{
_logger.LogDebug("Cache hit for product {ProductId}", id);
return JsonSerializer.Deserialize<Product>(cachedProduct);
}
var product = await _decorated.GetByIdAsync(id);
if (product != null)
{
var options = new DistributedCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(30)
};
await _cache.SetStringAsync(cacheKey,
JsonSerializer.Serialize(product), options);
}
return product;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error accessing cache for product {ProductId}", id);
// Fall back to decorated repository
return await _decorated.GetByIdAsync(id);
}
}
public async Task<T> AddAsync(T entity)
{
var result = await _decorated.AddAsync(entity);
await InvalidateCacheForProduct(result.Id);
return result;
}
public async Task UpdateAsync(T entity)
{
await _decorated.UpdateAsync(entity);
await InvalidateCacheForProduct(entity.Id);
}
public async Task DeleteAsync(T entity)
{
await _decorated.DeleteAsync(entity);
await InvalidateCacheForProduct(entity.Id);
}
private async Task InvalidateCacheForProduct(int productId)
{
var cacheKey = $"product_{productId}";
await _cache.RemoveAsync(cacheKey);
// Also remove related cache entries
await _cache.RemoveAsync("products_active");
await _cache.RemoveAsync("products_featured");
}}
Specification Pattern Integration
// Core/Specifications/BaseSpecification.cspublic abstract class BaseSpecification<T> where T : BaseEntity{
public Expression<Func<T, bool>> Criteria { get; }
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(Expression<Func<T, bool>> criteria)
{
Criteria = criteria;
}
protected virtual void AddInclude(Expression<Func<T, object>> includeExpression)
{
Includes.Add(includeExpression);
}
protected virtual void AddInclude(string includeString)
{
IncludeStrings.Add(includeString);
}
protected virtual void ApplyPaging(int skip, int take)
{
Skip = skip;
Take = take;
IsPagingEnabled = true;
}
protected virtual void ApplyOrderBy(Expression<Func<T, object>> orderByExpression)
{
OrderBy = orderByExpression;
}
protected virtual void ApplyOrderByDescending(Expression<Func<T, object>> orderByDescendingExpression)
{
OrderByDescending = orderByDescendingExpression;
}}
// Core/Specifications/ProductsWithCategoryAndReviewsSpecification.cspublic class ProductsWithCategoryAndReviewsSpecification : BaseSpecification<Product>{
public ProductsWithCategoryAndReviewsSpecification(ProductSpecParams productParams)
: base(p =>
(string.IsNullOrEmpty(productParams.Search) ||
p.Name.Contains(productParams.Search) ||
p.Description.Contains(productParams.Search)) &&
(!productParams.CategoryId.HasValue || p.CategoryId == productParams.CategoryId) &&
(!productParams.MinPrice.HasValue || p.Price >= productParams.MinPrice) &&
(!productParams.MaxPrice.HasValue || p.Price <= productParams.MaxPrice) &&
p.IsActive)
{
AddInclude(p => p.Category);
AddInclude(p => p.Reviews);
AddInclude(p => p.Inventory);
if (!string.IsNullOrEmpty(productParams.Sort))
{
switch (productParams.Sort)
{
case "priceAsc":
ApplyOrderBy(p => p.Price);
break;
case "priceDesc":
ApplyOrderByDescending(p => p.Price);
break;
case "nameAsc":
ApplyOrderBy(p => p.Name);
break;
case "nameDesc":
ApplyOrderByDescending(p => p.Name);
break;
case "ratingDesc":
ApplyOrderByDescending(p => p.Reviews.Average(r => r.Rating));
break;
default:
ApplyOrderBy(p => p.Name);
break;
}
}
ApplyPaging(productParams.PageSize * (productParams.PageIndex - 1), productParams.PageSize);
}
public ProductsWithCategoryAndReviewsSpecification(int id)
: base(p => p.Id == id)
{
AddInclude(p => p.Category);
AddInclude(p => p.Reviews);
AddInclude(p => p.Inventory);
}}
Generic Repository with Specification
// Core/Interfaces/IGenericRepository.cspublic interface IGenericRepository<T> where T : BaseEntity{
Task<T> GetByIdAsync(int id);
Task<IReadOnlyList<T>> GetAllAsync();
Task<T> GetEntityWithSpec(ISpecification<T> spec);
Task<IReadOnlyList<T>> ListAsync(ISpecification<T> spec);
Task<int> CountAsync(ISpecification<T> spec);
Task<T> AddAsync(T entity);
Task UpdateAsync(T entity);
Task DeleteAsync(T entity);}
// Infrastructure/Data/GenericRepository.cspublic class GenericRepository<T> : IGenericRepository<T> where T : BaseEntity{
private readonly ApplicationDbContext _context;
public GenericRepository(ApplicationDbContext context)
{
_context = context;
}
public async Task<T> GetByIdAsync(int id)
{
return await _context.Set<T>().FindAsync(id);
}
public async Task<T> GetEntityWithSpec(ISpecification<T> spec)
{
return await ApplySpecification(spec).FirstOrDefaultAsync();
}
public async Task<IReadOnlyList<T>> ListAsync(ISpecification<T> spec)
{
return await ApplySpecification(spec).ToListAsync();
}
public async Task<int> CountAsync(ISpecification<T> spec)
{
return await ApplySpecification(spec, true).CountAsync();
}
public async Task<T> AddAsync(T entity)
{
await _context.Set<T>().AddAsync(entity);
return entity;
}
public async Task UpdateAsync(T entity)
{
_context.Set<T>().Attach(entity);
_context.Entry(entity).State = EntityState.Modified;
await Task.CompletedTask;
}
public async Task DeleteAsync(T entity)
{
_context.Set<T>().Remove(entity);
await Task.CompletedTask;
}
public async Task<IReadOnlyList<T>> GetAllAsync()
{
return await _context.Set<T>().ToListAsync();
}
private IQueryable<T> ApplySpecification(ISpecification<T> spec, bool forCount = false)
{
return SpecificationEvaluator<T>.GetQuery(_context.Set<T>().AsQueryable(), spec, forCount);
}}
7. Testing Strategies
Unit Tests for Repository
// Tests/Unit/Infrastructure/Repositories/ProductRepositoryTests.cspublic class ProductRepositoryTests{
private readonly DbContextOptions<ApplicationDbContext> _dbContextOptions;
private readonly ApplicationDbContext _context;
private readonly ProductRepository _productRepository;
public ProductRepositoryTests()
{
_dbContextOptions = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
.Options;
_context = new ApplicationDbContext(_dbContextOptions);
_productRepository = new ProductRepository(_context);
}
[Fact]
public async Task GetByIdAsync_WhenProductExists_ReturnsProduct()
{
// Arrange
var product = new Product
{
Id = 1,
Name = "Test Product",
Price = 99.99m,
CategoryId = 1
};
await _context.Products.AddAsync(product);
await _context.SaveChangesAsync();
// Act
var result = await _productRepository.GetByIdAsync(1);
// Assert
result.Should().NotBeNull();
result.Id.Should().Be(1);
result.Name.Should().Be("Test Product");
}
[Fact]
public async Task GetProductsByCategoryAsync_WhenCategoryExists_ReturnsProducts()
{
// Arrange
var category = new Category { Id = 1, Name = "Electronics" };
var products = new List<Product>
{
new Product { Id = 1, Name = "Laptop", CategoryId = 1, IsActive = true },
new Product { Id = 2, Name = "Phone", CategoryId = 1, IsActive = true },
new Product { Id = 3, Name = "Tablet", CategoryId = 1, IsActive = false }
};
await _context.Categories.AddAsync(category);
await _context.Products.AddRangeAsync(products);
await _context.SaveChangesAsync();
// Act
var result = await _productRepository.GetProductsByCategoryAsync(1);
// Assert
result.Should().HaveCount(2);
result.All(p => p.CategoryId == 1).Should().BeTrue();
result.All(p => p.IsActive).Should().BeTrue();
}
[Fact]
public async Task UpdateProductStockAsync_WithValidQuantity_UpdatesStock()
{
// Arrange
var product = new Product
{
Id = 1,
Name = "Test Product",
StockQuantity = 50
};
await _context.Products.AddAsync(product);
await _context.SaveChangesAsync();
// Act
await _productRepository.UpdateProductStockAsync(1, -10);
// Assert
var updatedProduct = await _context.Products.FindAsync(1);
updatedProduct.StockQuantity.Should().Be(40);
}
[Fact]
public async Task UpdateProductStockAsync_WithInsufficientStock_ThrowsException()
{
// Arrange
var product = new Product
{
Id = 1,
Name = "Test Product",
StockQuantity = 5
};
await _context.Products.AddAsync(product);
await _context.SaveChangesAsync();
// Act & Assert
await Assert.ThrowsAsync<InvalidOperationException>(() =>
_productRepository.UpdateProductStockAsync(1, -10));
}}
Integration Tests
// Tests/Integration/OrderServiceIntegrationTests.cspublic class OrderServiceIntegrationTests : IClassFixture<CustomWebApplicationFactory>{
private readonly CustomWebApplicationFactory _factory;
private readonly IServiceScope _scope;
private readonly IOrderService _orderService;
private readonly IUnitOfWork _unitOfWork;
private readonly ApplicationDbContext _context;
public OrderServiceIntegrationTests(CustomWebApplicationFactory factory)
{
_factory = factory;
_scope = _factory.Services.CreateScope();
_orderService = _scope.ServiceProvider.GetRequiredService<IOrderService>();
_unitOfWork = _scope.ServiceProvider.GetRequiredService<IUnitOfWork>();
_context = _scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
}
[Fact]
public async Task CreateOrderAsync_WithValidRequest_CreatesOrderAndUpdatesInventory()
{
// Arrange
var customer = new Customer { Name = "Test Customer", Email = "[email protected]" };
var category = new Category { Name = "Electronics" };
var products = new List<Product>
{
new Product { Name = "Laptop", Price = 1000m, StockQuantity = 10, Category = category },
new Product { Name = "Mouse", Price = 25m, StockQuantity = 50, Category = category }
};
await _context.Customers.AddAsync(customer);
await _context.Categories.AddAsync(category);
await _context.Products.AddRangeAsync(products);
await _context.SaveChangesAsync();
var request = new CreateOrderRequest
{
CustomerId = customer.Id,
ShippingAddress = "123 Test St",
ShippingCity = "Test City",
ShippingZipCode = "12345",
Items = new List<OrderItemRequest>
{
new OrderItemRequest { ProductId = products[0].Id, Quantity = 1 },
new OrderItemRequest { ProductId = products[1].Id, Quantity = 2 }
},
PaymentMethod = "CreditCard"
};
// Act
var result = await _orderService.CreateOrderAsync(request);
// Assert
result.Success.Should().BeTrue();
result.OrderId.Should().BeGreaterThan(0);
var order = await _unitOfWork.Orders.GetOrderWithDetailsAsync(result.OrderId);
order.Should().NotBeNull();
order.TotalAmount.Should().Be(1050m); // 1000 + (25 * 2)
order.Status.Should().Be(OrderStatus.Confirmed);
// Verify inventory was updated
var laptop = await _unitOfWork.Products.GetByIdAsync(products[0].Id);
var mouse = await _unitOfWork.Products.GetByIdAsync(products[1].Id);
laptop.StockQuantity.Should().Be(9); // 10 - 1
mouse.StockQuantity.Should().Be(48); // 50 - 2
}}
Mocking Dependencies for Service Tests
// Tests/Unit/Application/Services/OrderServiceTests.cspublic class OrderServiceTests{
private readonly Mock<IUnitOfWork> _mockUnitOfWork;
private readonly Mock<IEmailService> _mockEmailService;
private readonly Mock<IPaymentService> _mockPaymentService;
private readonly Mock<ILogger<OrderService>> _mockLogger;
private readonly OrderService _orderService;
public OrderServiceTests()
{
_mockUnitOfWork = new Mock<IUnitOfWork>();
_mockEmailService = new Mock<IEmailService>();
_mockPaymentService = new Mock<IPaymentService>();
_mockLogger = new Mock<ILogger<OrderService>>();
_orderService = new OrderService(
_mockUnitOfWork.Object,
_mockEmailService.Object,
_mockPaymentService.Object,
_mockLogger.Object);
}
[Fact]
public async Task CreateOrderAsync_WithValidRequest_ReturnsSuccess()
{
// Arrange
var customer = new Customer { Id = 1, Email = "[email protected]" };
var products = new List<Product>
{
new Product { Id = 1, Name = "Laptop", Price = 1000m, StockQuantity = 10 },
new Product { Id = 2, Name = "Mouse", Price = 25m, StockQuantity = 50 }
};
var request = new CreateOrderRequest
{
CustomerId = 1,
Items = new List<OrderItemRequest>
{
new OrderItemRequest { ProductId = 1, Quantity = 1 },
new OrderItemRequest { ProductId = 2, Quantity = 2 }
}
};
// Setup mocks
_mockUnitOfWork.Setup(u => u.Customers.GetByIdAsync(1))
.ReturnsAsync(customer);
_mockUnitOfWork.Setup(u => u.Products.GetByIdAsync(1))
.ReturnsAsync(products[0]);
_mockUnitOfWork.Setup(u => u.Products.GetByIdAsync(2))
.ReturnsAsync(products[1]);
_mockPaymentService.Setup(p => p.ProcessPaymentAsync(It.IsAny<PaymentRequest>()))
.ReturnsAsync(new PaymentResult { Success = true });
_mockUnitOfWork.Setup(u => u.CommitAsync())
.ReturnsAsync(1);
// Act
var result = await _orderService.CreateOrderAsync(request);
// Assert
result.Success.Should().BeTrue();
_mockUnitOfWork.Verify(u => u.BeginTransactionAsync(), Times.Once);
_mockUnitOfWork.Verify(u => u.CommitTransactionAsync(), Times.Once);
_mockEmailService.Verify(e => e.SendOrderConfirmationAsync(
"[email protected]", It.IsAny<Order>()), Times.Once);
}}
8. Performance Optimization
Query Optimization Techniques
// Infrastructure/Data/Repositories/OptimizedProductRepository.cspublic class OptimizedProductRepository : IProductRepository{
private readonly ApplicationDbContext _context;
public OptimizedProductRepository(ApplicationDbContext context)
{
_context = context;
}
public async Task<IReadOnlyList<Product>> GetActiveProductsWithDetailsAsync()
{
// Use AsNoTracking for read-only operations
return await _context.Products
.AsNoTracking() // Improves performance for read-only
.Where(p => p.IsActive)
.Include(p => p.Category)
.Include(p => p.Inventory)
.Include(p => p.Reviews)
.Select(p => new Product // Use projection to load only needed fields
{
Id = p.Id,
Name = p.Name,
Price = p.Price,
DiscountPrice = p.DiscountPrice,
ImageUrl = p.ImageUrl,
Category = new Category { Name = p.Category.Name },
Reviews = p.Reviews.Select(r => new Review
{
Rating = r.Rating,
Comment = r.Comment
}).ToList(),
StockQuantity = p.Inventory.Quantity
})
.OrderBy(p => p.Category.Name)
.ThenBy(p => p.Name)
.ToListAsync();
}
public async Task<IReadOnlyList<Product>> SearchProductsAsync(string searchTerm, int pageNumber, int pageSize)
{
// Use compiled query for frequently executed queries
var compiledQuery = EF.CompileAsyncQuery(
(ApplicationDbContext context, string term, int skip, int take) =>
context.Products
.AsNoTracking()
.Where(p => p.IsActive &&
(p.Name.Contains(term) ||
p.Description.Contains(term)))
.Include(p => p.Category)
.OrderBy(p => p.Name)
.Skip(skip)
.Take(take)
.ToList());
return await compiledQuery(_context, searchTerm, (pageNumber - 1) * pageSize, pageSize);
}}
Bulk Operations
// Infrastructure/Data/Repositories/BulkOperationsRepository.cspublic class BulkOperationsRepository : IBulkOperationsRepository{
private readonly ApplicationDbContext _context;
public BulkOperationsRepository(ApplicationDbContext context)
{
_context = context;
}
public async Task BulkInsertProductsAsync(IEnumerable<Product> products)
{
// Use EF Core Bulk Extensions for large inserts
await _context.BulkInsertAsync(products, options =>
{
options.BatchSize = 1000;
options.UseTempDB = true;
});
}
public async Task BulkUpdateProductPricesAsync(IEnumerable<ProductPriceUpdate> updates)
{
// Use raw SQL for bulk updates
var updateQuery = @"UPDATE Products
SET Price = @Price, UpdatedAt = GETUTCDATE()
WHERE Id = @Id";
foreach (var update in updates.Batch(1000)) // Process in batches
{
foreach (var item in update)
{
await _context.Database.ExecuteSqlRawAsync(
updateQuery,
new SqlParameter("@Price", item.Price),
new SqlParameter("@Id", item.ProductId));
}
}
}}
9. Common Pitfalls & Best Practices
Common Pitfalls
// ❌ COMMON MISTAKES
// 1. Not handling transactions properlypublic async Task ProcessOrder(Order order){
// Missing transaction scope
await UpdateInventory(order);
await _orderRepository.AddAsync(order);
await _paymentRepository.AddAsync(payment);
// If payment fails, inventory is already updated!}
// 2. Repository returning IQueryable (leaks abstraction)public interface IProductRepository{
IQueryable<Product> GetAll(); // ❌ Don't do this!}
// 3. Not implementing proper disposalpublic class ProductService{
private readonly IUnitOfWork _unitOfWork;
public ProductService(IUnitOfWork unitOfWork)
{
_unitOfWork = unitOfWork;
}
// Missing IDisposable implementation}
// 4. Too many database round trips (N+1 problem)public async Task ProcessOrders(){
var orders = await _orderRepository.GetAllAsync();
foreach (var order in orders)
{
// This causes N+1 queries!
var customer = await _customerRepository.GetByIdAsync(order.CustomerId);
ProcessOrder(order, customer);
}}
Best Practices Implementation
// ✅ BEST PRACTICES
// 1. Proper transaction handlingpublic async Task<OrderResult> ProcessOrderAsync(Order order){
await _unitOfWork.BeginTransactionAsync();
try
{
await UpdateInventory(order);
await _unitOfWork.Orders.AddAsync(order);
await ProcessPayment(order);
await _unitOfWork.CommitAsync();
await _unitOfWork.CommitTransactionAsync();
return OrderResult.Success(order.Id);
}
catch (Exception ex)
{
await _unitOfWork.RollbackAsync();
_logger.LogError(ex, "Order processing failed");
return OrderResult.Failure(ex.Message);
}}
// 2. Use specific repository methods instead of IQueryablepublic interface IOrderRepository : IRepository<Order>{
// ✅ Specific, meaningful methods
Task<Order> GetOrderWithCustomerAndItemsAsync(int orderId);
Task<IReadOnlyList<Order>> GetRecentOrdersAsync(int count);
Task<IReadOnlyList<Order>> GetOrdersByStatusAsync(OrderStatus status);}
// 3. Implement proper disposal patternpublic class ProductService : IDisposable{
private readonly IUnitOfWork _unitOfWork;
private bool _disposed = false;
public ProductService(IUnitOfWork unitOfWork)
{
_unitOfWork = unitOfWork;
}
protected virtual void Dispose(bool disposing)
{
if (!_disposed && disposing)
{
_unitOfWork?.Dispose();
}
_disposed = true;
}
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}}
// 4. Use eager loading and batching to avoid N+1public async Task ProcessOrders(){
// Load all orders with customers in one query
var orders = await _orderRepository.GetOrdersWithCustomersAsync();
// Process in memory
foreach (var order in orders)
{
ProcessOrder(order, order.Customer); // Customer already loaded
}}
10. Alternatives & When to Use What
CQRS Pattern Alternative
// Core/CQRS/Commands/CreateProductCommand.cspublic class CreateProductCommand : IRequest<CommandResult>{
public string Name { get; set; }
public string Description { get; set; }
public decimal Price { get; set; }
public int CategoryId { get; set; }
public int StockQuantity { get; set; }}
// Core/CQRS/Handlers/CreateProductCommandHandler.cspublic class CreateProductCommandHandler : IRequestHandler<CreateProductCommand, CommandResult>{
private readonly IUnitOfWork _unitOfWork;
private readonly ILogger<CreateProductCommandHandler> _logger;
public CreateProductCommandHandler(
IUnitOfWork unitOfWork,
ILogger<CreateProductCommandHandler> logger)
{
_unitOfWork = unitOfWork;
_logger = logger;
}
public async Task<CommandResult> Handle(CreateProductCommand request, CancellationToken cancellationToken)
{
try
{
var product = new Product
{
Name = request.Name,
Description = request.Description,
Price = request.Price,
CategoryId = request.CategoryId,
StockQuantity = request.StockQuantity
};
await _unitOfWork.Products.AddAsync(product);
await _unitOfWork.CommitAsync();
_logger.LogInformation("Product {ProductId} created successfully", product.Id);
return CommandResult.Success(product.Id);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error creating product");
return CommandResult.Failure(ex.Message);
}
}}
// Core/CQRS/Queries/GetProductQuery.cspublic class GetProductQuery : IRequest<ProductDto>{
public int ProductId { get; set; }}
// Core/CQRS/Handlers/GetProductQueryHandler.cspublic class GetProductQueryHandler : IRequestHandler<GetProductQuery, ProductDto>{
private readonly IReadOnlyProductRepository _readRepository;
public GetProductQueryHandler(IReadOnlyProductRepository readRepository)
{
_readRepository = readRepository;
}
public async Task<ProductDto> Handle(GetProductQuery request, CancellationToken cancellationToken)
{
return await _readRepository.GetProductDtoAsync(request.ProductId);
}}
When to Use Repository vs CQRS vs Direct DbContext
| Pattern | Use Case | Pros | Cons |
|---|
| Repository + UoW | Medium complexity apps, team consistency, testing focus | Good abstraction, testable, consistent data access | Can be overkill for simple apps, some abstraction leakage |
| CQRS | High-performance apps, complex read/write requirements | Scalable, optimized queries, clear separation | More complex, eventual consistency challenges |
| Direct DbContext | Simple CRUD apps, prototypes, small teams | Fast development, full EF power | Hard to test, business logic in controllers |
| Generic Repository | Rapid development, consistent basic operations | Reduces boilerplate, consistent interface | Limited flexibility for complex queries |
Decision Framework
public class PatternSelectionService{
public DataAccessPattern SelectPattern(ApplicationRequirements requirements)
{
return requirements switch
{
{ Complexity: Complexity.Simple, TeamSize: < 3 }
=> DataAccessPattern.DirectDbContext,
{ Complexity: Complexity.Medium, NeedsTesting: true }
=> DataAccessPattern.RepositoryUnitOfWork,
{ Complexity: Complexity.High, ReadWriteRatio: > 5 }
=> DataAccessPattern.CQRS,
{ NeedsRapidDevelopment: true, ConsistencyImportant: false }
=> DataAccessPattern.GenericRepository,
_ => DataAccessPattern.RepositoryUnitOfWork
};
}}
public enum DataAccessPattern{
DirectDbContext,
RepositoryUnitOfWork,
CQRS,
GenericRepository
}
public class ApplicationRequirements{
public Complexity Complexity { get; set; }
public int TeamSize { get; set; }
public bool NeedsTesting { get; set; }
public double ReadWriteRatio { get; set; }
public bool NeedsRapidDevelopment { get; set; }
public bool ConsistencyImportant { get; set; }}
🎯 Conclusion
The Repository and Unit of Work patterns provide a robust foundation for building maintainable, testable, and scalable ASP.NET Core applications. While they introduce some complexity, the benefits in terms of testability, maintainability, and team consistency make them invaluable for enterprise applications.
Key Takeaways
Use these patterns when building applications that require testing and maintainability
Implement proper transaction handling for complex operations
Consider alternatives like CQRS for high-performance scenarios
Always follow best practices for disposal and error handling
Use the pattern that best fits your application's complexity and team size
Remember, patterns are tools - use them wisely based on your specific requirements rather than applying them blindly.
This comprehensive guide provides everything needed to implement Repository and Unit of Work patterns effectively in ASP.NET Core applications, from basic concepts to advanced production-ready implementations.