
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 });
}
}}

Join the conversation! Your thoughts help the community grow.