Master Repository & Unit of Work Patterns in ASP.NET Core: Build Testable, Maintainable Data Access Layers
Learn how to implement robust data access patterns that make your applications scalable, testable, and maintainable with real-world examples and production-ready code.
📋 Table of Contents
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 controllers public 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 controllers public 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 patterns public 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.cs public 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.cs public 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.cs public 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.cs public 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.cs public 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.cs public 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.cs public 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.cs public 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.cs public 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.cs public 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.cs public enum OrderStatus { Pending, Confirmed, Processing, Shipped, Delivered, Cancelled, Refunded }
Advanced Service Layer
// Core/Interfaces/IOrderService.cs public 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.cs public 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.cs public 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.cs public 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.cs public 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.cs public 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.cs public 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.cs public 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.cs public 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 = "test@example.com" }; 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.cs public 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 = "test@example.com" }; 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( "test@example.com", It.IsAny<Order>()), Times.Once); } }
8. Performance Optimization
Query Optimization Techniques
// Infrastructure/Data/Repositories/OptimizedProductRepository.cs public 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.cs public 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 properly public 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 disposal public 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 handling public 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 IQueryable public 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 pattern public 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+1 public 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.cs public 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.cs public 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.cs public class GetProductQuery : IRequest<ProductDto> { public int ProductId { get; set; } } // Core/CQRS/Handlers/GetProductQueryHandler.cs public 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.
.jpg)
0 Comments
thanks for your comments!