Saturday, October 12, 2024

Implementing the Cache-Aside Pattern in .NET 8.0 APIs with Entity Framework Core using Redis Cache

Introduction

In this blog post, we'll explore how to implement the Cache-Aside pattern in a .NET 8.0 API using Entity Framework Core. We'll focus on a real-world scenario: an online transaction processing system for an e-commerce platform. By the end of this post, you'll understand how to effectively use caching to improve your API's performance and reduce database load.

What is the Cache-Aside Pattern?

The Cache-Aside pattern is a caching strategy where the application is responsible for maintaining the cache. When data is requested, the application first checks the cache. If the data is not found (a cache miss), it retrieves the data from the database, stores it in the cache, and then returns it to the caller.

Our Scenario: E-commerce Order Processing

We'll build an API for an e-commerce platform that handles order processing. Our focus will be on the following operations:

  1. Retrieving product details
  2. Placing an order
  3. Retrieving order status

Setting Up the Project

First, let's set up our .NET 8.0 API project with Entity Framework Core.

bash
dotnet new webapi -n ECommerceApi cd ECommerceApi dotnet add package Microsoft.EntityFrameworkCore.SqlServer dotnet add package Microsoft.Extensions.Caching.StackExchangeRedis

Implementing the Data Models

Let's create our data models for products and orders.

csharp
public class Product { public int Id { get; set; } public string Name { get; set; } public decimal Price { get; set; } public int StockQuantity { get; set; } } public class Order { public int Id { get; set; } public string CustomerEmail { get; set; } public DateTime OrderDate { get; set; } public decimal TotalAmount { get; set; } public string Status { get; set; } public List<OrderItem> Items { get; set; } } public class OrderItem { public int Id { get; set; } public int ProductId { get; set; } public int Quantity { get; set; } public decimal UnitPrice { get; set; } }

Setting Up Entity Framework Core

Now, let's set up our DbContext and configure Entity Framework Core.

csharp
public class ECommerceContext : DbContext { public ECommerceContext(DbContextOptions<ECommerceContext> options) : base(options) { } public DbSet<Product> Products { get; set; } public DbSet<Order> Orders { get; set; } public DbSet<OrderItem> OrderItems { get; set; } protected override void OnModelCreating(ModelBuilder modelBuilder) { // Configure your entity relationships and constraints here } }

Add the following to your Program.cs:

csharp
builder.Services.AddDbContext<ECommerceContext>(options => options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection")));

Implementing the Cache-Aside Pattern

We'll use Redis as our distributed cache. Add the following to your Program.cs:

csharp
builder.Services.AddStackExchangeRedisCache(options => { options.Configuration = builder.Configuration.GetConnectionString("RedisConnection"); options.InstanceName = "ECommerceCache_"; });

Now, let's create a caching service that implements the Cache-Aside pattern:

csharp
public interface ICacheService { Task<T> GetOrSetAsync<T>(string key, Func<Task<T>> getItemCallback, TimeSpan expirationTime); Task RemoveAsync(string key); } public class RedisCacheService : ICacheService { private readonly IDistributedCache _cache; private readonly ILogger<RedisCacheService> _logger; public RedisCacheService(IDistributedCache cache, ILogger<RedisCacheService> logger) { _cache = cache; _logger = logger; } public async Task<T> GetOrSetAsync<T>(string key, Func<Task<T>> getItemCallback, TimeSpan expirationTime) { var cachedResult = await _cache.GetStringAsync(key); if (cachedResult != null) { _logger.LogInformation("Cache hit for key: {Key}", key); return JsonSerializer.Deserialize<T>(cachedResult); } _logger.LogInformation("Cache miss for key: {Key}", key); var result = await getItemCallback(); await _cache.SetStringAsync(key, JsonSerializer.Serialize(result), new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = expirationTime }); return result; } public async Task RemoveAsync(string key) { await _cache.RemoveAsync(key); _logger.LogInformation("Removed cache for key: {Key}", key); } }

Register the cache service in Program.cs:

csharp
builder.Services.AddSingleton<ICacheService, RedisCacheService>();

Implementing the API Endpoints

Now, let's implement our API endpoints using the Cache-Aside pattern.

Product Controller

csharp
[ApiController] [Route("api/[controller]")] public class ProductsController : ControllerBase { private readonly ECommerceContext _context; private readonly ICacheService _cacheService; private readonly ILogger<ProductsController> _logger; public ProductsController(ECommerceContext context, ICacheService cacheService, ILogger<ProductsController> logger) { _context = context; _cacheService = cacheService; _logger = logger; } [HttpGet("{id}")] public async Task<ActionResult<Product>> GetProduct(int id) { var cacheKey = $"product_{id}"; var product = await _cacheService.GetOrSetAsync(cacheKey, async () => { _logger.LogInformation("Fetching product {Id} from database", id); return await _context.Products.FindAsync(id); }, TimeSpan.FromMinutes(10)); if (product == null) { return NotFound(); } return product; } [HttpPost] public async Task<ActionResult<Product>> CreateProduct(Product product) { _context.Products.Add(product); await _context.SaveChangesAsync(); // Invalidate cache for this product await _cacheService.RemoveAsync($"product_{product.Id}"); return CreatedAtAction(nameof(GetProduct), new { id = product.Id }, product); } [HttpPut("{id}")] public async Task<IActionResult> UpdateProduct(int id, Product product) { if (id != product.Id) { return BadRequest(); } _context.Entry(product).State = EntityState.Modified; try { await _context.SaveChangesAsync(); // Invalidate cache for this product await _cacheService.RemoveAsync($"product_{id}"); } catch (DbUpdateConcurrencyException) { if (!await ProductExists(id)) { return NotFound(); } else { throw; } } return NoContent(); } private async Task<bool> ProductExists(int id) { return await _context.Products.AnyAsync(e => e.Id == id); } }

Order Controller

csharp
[ApiController] [Route("api/[controller]")] public class OrdersController : ControllerBase { private readonly ECommerceContext _context; private readonly ICacheService _cacheService; private readonly ILogger<OrdersController> _logger; public OrdersController(ECommerceContext context, ICacheService cacheService, ILogger<OrdersController> logger) { _context = context; _cacheService = cacheService; _logger = logger; } [HttpPost] public async Task<ActionResult<Order>> PlaceOrder(Order order) { using var transaction = await _context.Database.BeginTransactionAsync(); try { foreach (var item in order.Items) { var product = await _context.Products.FindAsync(item.ProductId); if (product == null || product.StockQuantity < item.Quantity) { throw new InvalidOperationException($"Insufficient stock for product {item.ProductId}"); } product.StockQuantity -= item.Quantity; _context.Entry(product).State = EntityState.Modified; // Invalidate cache for this product await _cacheService.RemoveAsync($"product_{item.ProductId}"); } order.OrderDate = DateTime.UtcNow; order.Status = "Pending"; _context.Orders.Add(order); await _context.SaveChangesAsync(); await transaction.CommitAsync(); return CreatedAtAction(nameof(GetOrder), new { id = order.Id }, order); } catch (Exception ex) { await transaction.RollbackAsync(); _logger.LogError(ex, "Error placing order"); return StatusCode(500, "An error occurred while placing the order."); } } [HttpGet("{id}")] public async Task<ActionResult<Order>> GetOrder(int id) { var cacheKey = $"order_{id}"; var order = await _cacheService.GetOrSetAsync(cacheKey, async () => { _logger.LogInformation("Fetching order {Id} from database", id); return await _context.Orders .Include(o => o.Items) .FirstOrDefaultAsync(o => o.Id == id); }, TimeSpan.FromMinutes(5)); if (order == null) { return NotFound(); } return order; } [HttpPut("{id}/status")] public async Task<IActionResult> UpdateOrderStatus(int id, string status) { var order = await _context.Orders.FindAsync(id); if (order == null) { return NotFound(); } order.Status = status; await _context.SaveChangesAsync(); // Invalidate cache for this order await _cacheService.RemoveAsync($"order_{id}"); return NoContent(); } }

Performance Considerations

  1. Cache Expiration: We've set different expiration times for products (10 minutes) and orders (5 minutes). Adjust these based on your specific requirements and data volatility.
  2. Cache Invalidation: We invalidate the cache when products are updated or when order statuses change. This ensures that the cached data remains consistent with the database.
  3. Batch Operations: For high-volume scenarios, consider implementing batch cache operations to reduce network overhead.
  4. Monitoring: Implement proper logging and monitoring to track cache hit/miss ratios and identify potential bottlenecks.

Conclusion

We've implemented the Cache-Aside pattern in our .NET 8.0 API using Entity Framework Core and Redis. This approach significantly reduces database load for read-heavy operations while ensuring data consistency for write operations.

Key takeaways:

  1. The Cache-Aside pattern improves performance for frequently accessed, relatively static data.
  2. Proper cache invalidation is crucial to maintain data consistency.
  3. Use distributed caching (like Redis) for scalability in multi-instance deployments.
  4. Adjust cache expiration times based on your data's volatility and consistency requirements.

By following these practices, you can build high-performance, scalable APIs that can handle the demands of modern e-commerce platforms.