DEV Community

Ben Witt
Ben Witt

Posted on • Updated on • Originally published at Medium

Implementing the Cached Repository Pattern in C#

Introduction to the Concept of a Cached Repository

A cached repository is a design pattern aimed at enhancing application performance by storing data in a fast-access memory area known as a cache. This reduces the number of database accesses, thereby improving response times and the application's scalability. A repository abstracts data access and provides uniform interfaces for CRUD operations (Create, Read, Update, Delete). Combining these concepts offers a powerful method for optimizing data access patterns in modern applications.

Importance and Benefits of Using Cached Repositories in C# for Advanced Developers

For advanced developers, cached repositories offer several advantages:

  • Performance Improvement: Reducing database accesses significantly enhances response times.
  • Scalability: Lower database load facilitates better application scalability.
  • Cost Reduction: Fewer database queries translate to lower costs, especially with cloud services billed per query.
  • Consistency and Abstraction: Using a uniform repository ensures consistent data access and allows for easy abstraction and testing.

Detailed Implementation of a Cached Repository in C# Using the Decorator Pattern and EF Core

Implementing a cached repository can be effectively achieved through the decorator pattern. This pattern allows additional functionality to be added to an object without altering its structure.

Define the Repository Interface

public interface IProductRepository
{
    Task<Product> GetProductByIdAsync(int id);
    Task<IEnumerable<Product>> GetAllProductsAsync();
    Task AddProductAsync(Product product);
    Task UpdateProductAsync(Product product);
    Task DeleteProductAsync(int id);
}
Enter fullscreen mode Exit fullscreen mode

Implement the Base Repository with EF Core

public class ProductRepository : IProductRepository
{
    private readonly ApplicationDbContext _context;

    public ProductRepository(ApplicationDbContext context)
    {
        _context = context;
    }

    public async Task<Product> GetProductByIdAsync(int id)
    {
        return await _context.Products.FirstOrDefaultAsync(x => x.Id == id);
    }

    public async Task<IEnumerable<Product>> GetAllProductsAsync()
    {
        return await _context.Products.ToListAsync();
    }

    public async Task AddProductAsync(Product product)
    {
        await _context.Products.AddAsync(product);
        await _context.SaveChangesAsync();
    }

    public async Task UpdateProductAsync(Product product)
    {
        _context.Products.Update(product);
        await _context.SaveChangesAsync();
    }

    public async Task DeleteProductAsync(int id)
    {
        var product = await _context.Products.FindAsync(id);
        if (product != null)
        {
            _context.Products.Remove(product);
            await _context.SaveChangesAsync();
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Implement the Cached Repository

public class CachedProductRepository : IProductRepository
{
    private readonly IProductRepository _repository;
    private readonly IMemoryCache _cache;
    private readonly TimeSpan _cacheDuration = TimeSpan.FromMinutes(5);

    public CachedProductRepository(IProductRepository repository, IMemoryCache cache)
    {
        _repository = repository;
        _cache = cache;
    }

    public async Task<Product> GetProductByIdAsync(int id)
    {
        string cacheKey = $"Product_{id}";
        if (!_cache.TryGetValue(cacheKey, out Product product))
        {
            product = await _repository.GetProductByIdAsync(id);
            if (product != null)
            {
                _cache.Set(cacheKey, product, _cacheDuration);
            }
        }
        return product;
    }

    public async Task<IEnumerable<Product>> GetAllProductsAsync()
    {
        const string cacheKey = "AllProducts";
        if (!_cache.TryGetValue(cacheKey, out IEnumerable<Product> products))
        {
            products = await _repository.GetAllProductsAsync();
            _cache.Set(cacheKey, products, _cacheDuration);
        }
        return products;
    }

    public async Task AddProductAsync(Product product)
    {
        await _repository.AddProductAsync(product);
        InvalidateCache();
    }

    public async Task UpdateProductAsync(Product product)
    {
        await _repository.UpdateProductAsync(product);
        InvalidateCache();
    }

    public async Task DeleteProductAsync(int id)
    {
        await _repository.DeleteProductAsync(id);
        InvalidateCache();
    }

    private void InvalidateCache()
    {
        _cache.Remove("AllProducts");
    }
}
Enter fullscreen mode Exit fullscreen mode

Best Practices and Potential Pitfalls in Using Cached Repositories in C#

Best Practices:

  • Cache Invalidation: Ensure the cache is invalidated after write operations (Add, Update, Delete) to maintain consistency.
  • Cache Duration: Choose an appropriate cache duration to balance freshness and performance.
  • Memory Management: Avoid overloading the cache, especially in memory-intensive applications.

Potential Pitfalls:

  • Stale Data: Cached data can become outdated, leading to inconsistencies.
  • Complexity: Implementing and managing cached repositories increases codebase complexity.
  • Memory Consumption: Excessive caching can lead to high memory usage and potential out-of-memory issues.

Comparison with Other Caching Strategies and Their Applications

In addition to the decorator pattern for cached repositories, there are several other caching strategies:

  • In-Memory Caching: Direct use of in-memory data stores like IMemoryCache or ConcurrentDictionary. Ideal for short-term, small data sets.
  • Distributed Caching: Use of distributed caches like Redis or Memcached. Suitable for applications with high scalability requirements.
  • HTTP Caching: Use of HTTP headers to cache web resources. Ideal for high-traffic web applications.

Each strategy has specific use cases and challenges that need to be carefully evaluated.

Advanced Topics: Cache Invalidation and Synchronization Between Cache and Database

Cache invalidation and synchronization are complex topics that require special attention:

Cache Invalidation:

  • Time-to-Live (TTL): Set a TTL for cache entries to ensure automatic invalidation.
  • Event-Based Invalidation: Use events or message queues to synchronize cache invalidations in distributed systems.

Synchronization Between Cache and Database:

  • Write-Through Caching: Write operations are performed on both the database and the cache, ensuring consistency.
  • Write-Behind Caching: Write operations are initially performed on the cache and later synchronized with the database. This can improve performance but carries the risk of data inconsistency in the event of a crash.
  • Cache Priming: Preload frequently accessed data into the cache at application startup to avoid initial latencies.

A comprehensive understanding and correct implementation of these techniques are crucial for successfully leveraging cached repositories in demanding applications.

In summary, cached repositories, combined with the decorator pattern and Entity Framework Core, offer an effective method for optimizing data access patterns. They provide significant performance benefits but require careful implementation and management to avoid potential pitfalls.

Top comments (5)

Collapse
 
vahidn profile image
Vahid Nasiri

Or you can use the EFCoreSecondLevelCacheInterceptor with less complexity without warping anything inside anything and with automatic cache invalidation.

Collapse
 
pter_n_e219fd4e5295d0a7 profile image
Péter N.

I'm thinking about whether read-only datasets could be stored in singleton services...
Anyway I like this example above!

Collapse
 
truonglx8993 profile image
TruongLX8993

I don't think this is a good approach.

  • When one request writes (no commit), and others read then becomes read uncommitted. The data can be impacted by other requests. I think you should separate for read/write repository or cqrs pattern
Collapse
 
ben-witt profile image
Ben Witt

Try to understand the code. Your example cannot occur in this code example.
This article is intended to explain the approach and not to provide a general solution for all problems.

Collapse
 
kspooky profile image
kgaska

If you are using caching for Ef.Core entities, you should ensure they are always returned as untracked. Otherwise you may end up in situation in you will have untracked entity in cache and exactly the same entity tracked by DbContext, because you just retrieved some another entity which was referencing it and wasn't in cache yet. This will lead to InvalidOperationException. So, in base repository AsNoTracking() should be used. But even then, calling Update or Add will start tracking an entity which may lead to exactly the same situation.

That's why I believe caching make sense only when using CQRS pattern. Then you can safely use cache just for READS (so returned entities cannot be reused for Write operations) and tracked entities for WRITES (always getting data from database). Because, eventually, business logic should be agnostic about the cache.

Generally speaking caching in Ef.Core is very complex topic and in most cases it is oversimplified like in this blog post. Even when taking a very simple textbook example of an Order entity which holds a reference to Customer and collection of Products entities. How would you cache it? With Customer and Products or without? How would you invalidate cache of Orders if Customer or Product has changed? Should you have a separate cache entries for each each Customer Orders or keep them all together? Domain is rarely build from some flat entities.

Some comments may only be visible to logged-in visitors. Sign in to view all comments. Some comments have been hidden by the post's author - find out more