How to Create Custom In-Memory Cache in ASP.NET Core Web API

How to Create Custom In-Memory Cache in ASP.NET Core Web API

In this article, we will discuss how to Create a Custom In-Memory Cache in ASP.NET Core Web API to Perform Get, Set, GetAll, Remove, and Clear operations. We will also work with the same example that we created in our previous article. Please read our previous article before proceeding with this article, in which we discussed how to implement an in-memory cache in ASP.NET Core Web API.

Define the Cache Interface

First, we need to define an interface that describes the operations our in-memory cache will perform. So, create an interface named ICustomCache.cs within the Data folder and then copy and paste the following code:

using Microsoft.Extensions.Caching.Memory;
namespace InMemoryCachingDemo.Data
{
    public interface ICustomCache
    {
        T Get<T>(string key);
        void Set<T>(string key, T value, TimeSpan? absoluteExpireTime = null, TimeSpan? slidingExpiration = null, CacheItemPriority priority = CacheItemPriority.Normal);
        IDictionary<string, object> GetAllCaches();
        void Remove(string key);
        void Clear(); 
    }
}
T Get<T>(string key)

This method retrieves an item from the In-Memory Cache. It takes a string key as a parameter, identifying the cached item. It returns the cached item cast to the type specified by T. If the item associated with the specified key does not exist or is invalid, it will return null or throw an exception, depending on the implementation.

void Set<T>(string key, T value, TimeSpan? absoluteExpireTime = null, TimeSpan? slidingExpiration = null, CacheItemPriority priority = CacheItemPriority.Normal)

This method is used to add an item to the In-Memory Cache or update an existing item with the given key. The meaning of the parameters are as follows:

  • T value: The data to cache.
  • TimeSpan? absoluteExpireTime: The absolute expiration time indicates when the item should expire and be removed from the cache regardless of whether it was accessed or not. If null, the item does not expire by absolute expiration.
  • TimeSpan? slidingExpiration: The sliding expiration time resets every time the cached item is accessed, extending the life of the cache entry by the specified amount of time. If null, the item does not have a sliding expiration.
  • CacheItemPriority priority: Determines the priority of keeping the entry in the cache during a memory pressure situation. The default is set to Normal.
IDictionary<string, object> GetAllCaches()

This method retrieves all items in the In-Memory Cache as a dictionary, where the key is the string identifier of the cache item, and the value is the item itself. This is useful for debugging or monitoring purposes, allowing one to view all cached data at a particular moment.

void Remove(string key)

This method removes an item from the In-Memory Cache using its key explicitly. It is useful when cached data becomes outdated or needs to be refreshed before its expiration.

void Clear()

This method clears all items from the In-Memory Cache. 

Implement the ICustomCache Interface

Next, we need to create a class implementing the ICustomCache interface. So, create a class file named CustomCache.cs within the Data folder and copy and paste the following code. The following CustomCache class provides implementations for all the ICustomCache interface methods. The following code is self-explained, so please read the comment lines for a better understanding.

using Microsoft.Extensions.Caching.Memory;

namespace InMemoryCachingDemo.Data
{
    public class CustomCache : ICustomCache
    {
        //This is the core component of the class, representing the actual in-memory cache provided by ASP.NET Core. 
        //It is used to store, retrieve, and manage cache entries.
        private readonly IMemoryCache _memoryCache;

        //HashSet is used to track all keys of the cache entries. 
        //It helps manage the keys effectively, especially when retrieving all cache items or clearing the cache.
        private readonly HashSet<string> _keys = new HashSet<string>();

        //Constructor accepts an IMemoryCache instance and initializes the _memoryCache field.
        //This ensures that the class can use the memory cache service provided by the framework.
        public CustomCache(IMemoryCache memoryCache)
        {
            _memoryCache = memoryCache;
        }

        //To Retrieve a value from the Cache using a Key
        public T Get<T>(string key)
        {
            //It uses the TryGetValue method of IMemoryCache, which attempts to get the value associated with the specified key. 
            //If the key exists, it returns the value; otherwise, it returns the default value for the type T
            _memoryCache.TryGetValue(key, out T value);
            return value;
        }

        //To Store a Key-value in the Cache
        public void Set<T>(string key, T value, TimeSpan? absoluteExpireTime = null, TimeSpan? slidingExpiration = null, CacheItemPriority priority = CacheItemPriority.Normal)
        {
            // Create a new instance of MemoryCacheEntryOptions for each invocation to avoid side effects
            var _options = new MemoryCacheEntryOptions
            {
                // Apply absolute expiration if provided
                AbsoluteExpirationRelativeToNow = absoluteExpireTime,
                // Apply sliding expiration if provided
                SlidingExpiration = slidingExpiration,
                // Set the priority of the cache item
                Priority = priority
            };

            // Set the cache item with the options
            _memoryCache.Set(key, value, _options);

            // Add the key to the tracking set to maintain a list of all keys
            _keys.Add(key);
        }

        //Retrieves all key-value pairs in the cache. 
        //It also checks each key from _keys to ensure they are still valid and cleans up any keys corresponding to expired entries.
        public IDictionary<string, object> GetAllCaches()
        {
            var allItems = new Dictionary<string, object>();
            foreach (var key in _keys.ToList()) // ToList to avoid collection modification issues
            {
                if (_memoryCache.TryGetValue(key, out object value))
                {
                    allItems[key] = value;
                }
                else
                {
                    // Item might have expired and removed, clean up keys
                    _keys.Remove(key);
                }
            }
            return allItems;
        }

        //Removes a specific entry from the cache and also deletes the corresponding key from _keys, 
        //ensuring that the tracking remains accurate.
        public void Remove(string key)
        {
            // Remove the cache item
            _memoryCache.Remove(key);

            // Also remove the key from the HashSet tracking all keys
            _keys.Remove(key);
        }

        //Clears all entries from the cache and all keys from _keys. 
        //This method ensures that the cache is completely reset, removing all entries and the memory associated with them.
        public void Clear()
        {
            // Clear all the Cache
            if (_memoryCache is MemoryCache concreteMemoryCache)
            {
                concreteMemoryCache.Clear();
            }

            // Clear the set of keys after clearing the cache
            _keys.Clear();
        }
    }
}
Register the Cache Service in the DI Container

Next, we need to register the Memory Cache, CustomCache, and ICustomCachein the dependency injection (DI) container so it can be used throughout our application. So, add the following code to the Program.cs class file:

builder.Services.AddMemoryCache();
builder.Services.AddSingleton<ICustomCache, CustomCache>();
Use the Cache in Your LocationRepository

Inject the cache service into LocationRepository, where we need caching functionality. So, modify the LocationRepository.cs file as follows:

using InMemoryCachingDemo.Data;
using InMemoryCachingDemo.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory;

namespace InMemoryCachingDemo.Repository
{
    public class LocationRepository
    {
        private readonly ApplicationDbContext _context;
        private readonly ICustomCache _cache;

        public LocationRepository(ApplicationDbContext context, ICustomCache cache)
        {
            _context = context;
            _cache = cache;
        }

        //Using Manual Eviction and High Priority for countries
        public async Task<List<Country>> GetCountriesAsync()
        {
            var cacheKey = "Countries";

            var countries = _cache.Get<List<Country>>(cacheKey);
            if (countries == null)
            {
                countries = await _context.Countries.ToListAsync();
                if (countries != null)
                {
                    _cache.Set(key: cacheKey, value: countries, priority: CacheItemPriority.High);
                }
            }

            return countries ?? new List<Country>();
        }

        // This Methid can be called after updating or deleting country data.
        public void RemoveCountriesFromCache()
        {
            var cacheKey = "Countries";
            _cache.Remove(cacheKey);
        }

        public async Task UpdateCountry(Country updatedCountry)
        {
            _context.Countries.Update(updatedCountry);
            await _context.SaveChangesAsync();
            RemoveCountriesFromCache();
        }

        // Using Sliding Expiration and Normal Priority for States
        public async Task<List<State>> GetStatesAsync(int countryId)
        {
            string cacheKey = $"States_{countryId}";
            var states = _cache.Get<List<State>>(cacheKey);
            if (states == null)
            {
                states = await _context.States.Where(s => s.CountryId == countryId).ToListAsync();
                if (states != null)
                {
                    _cache.Set(key: cacheKey, value: states, slidingExpiration: TimeSpan.FromMinutes(30)); 
                }
            }
            return states;
        }

        // Using Absolute Expiration and Low Priority for Cities
        public async Task<List<City>> GetCitiesAsync(int stateId)
        {
            string cacheKey = $"Cities_{stateId}";
            var cities = _cache.Get<List<City>>(cacheKey);
            if (cities == null)
            {
                cities = await _context.Cities.Where(c => c.StateId == stateId).ToListAsync();
                if (cities != null)
                {
                    _cache.Set(key: cacheKey, value: cities, 
                        absoluteExpireTime: TimeSpan.FromMinutes(30), priority: CacheItemPriority.Low);
                }
            }
            return cities;
        }
    }
}
Register the LocationRepository in the DI Container

Next, we need to register our LocationRepository in the dependency injection (DI) container. So, add the following code to the Program.cs class file:

builder.Services.AddScoped<LocationRepository>();

Modify the Location Controller:

Next, we need to modify the Location Controller as follows:

using InMemoryCachingDemo.Data;
using InMemoryCachingDemo.Models;
using InMemoryCachingDemo.Repository;
using Microsoft.AspNetCore.Mvc;

namespace InMemoryCachingDemo.Controllers
{
    [Route("api/[controller]")]
    [ApiController]
    public class LocationController : ControllerBase
    {
        private readonly LocationRepository _repository;
        private readonly ICustomCache _cache;

        public LocationController(LocationRepository repository, ICustomCache cache)
        {
            _repository = repository;
            _cache = cache;
        }

        [HttpGet("countries")]
        public async Task<IActionResult> GetCountries()
        {
            var countries = await _repository.GetCountriesAsync();
            return Ok(countries);
        }

        [HttpPut("countries/{id}")]
        public async Task<IActionResult> UpdateCountry(int id, Country country)
        {
            if (id != country.CountryId)
                return BadRequest();

            try
            {
                await _repository.UpdateCountry(country);
                return NoContent(); // Indicates success with no content to return
            }
            catch (Exception ex)
            {
                if (!CountryExists(id))
                    return NotFound();
                else
                {
                    var customResponse = new
                    {
                        Code = 500,
                        Message = "Internal Server Error",
                        // Do not expose the actual error to the client
                        ErrorMessage = ex.Message
                    };
                    return StatusCode(StatusCodes.Status500InternalServerError, customResponse);
                }
            }
        }

        private bool CountryExists(int id)
        {
            return _repository.GetCountriesAsync().Result.Any(e => e.CountryId == id);
        }

        [HttpGet("states/{countryId}")]
        public async Task<IActionResult> GetStates(int countryId)
        {
            var states = await _repository.GetStatesAsync(countryId);
            return Ok(states);
        }

        [HttpGet("cities/{stateId}")]
        public async Task<IActionResult> GetCities(int stateId)
        {
            var cities = await _repository.GetCitiesAsync(stateId);
            return Ok(cities);
        }

        [HttpGet("AllCaches")]
        public IActionResult GetAllCaches()
        {
            var allCaches = _cache.GetAllCaches();
            return Ok(allCaches);
        }

        [HttpPost("ClearCache")]
        public IActionResult ClearCache()
        {
            _cache.Clear();
            return Ok("All Cache Cleared");
        }
    }
}

With the above changes in place, run the application and test functionalities, and it should work as expected.

Asynchronous In-Memory Caching in ASP.NET Core Web API:

Asynchronous In-Memory Caching in ASP.NET Core Web API Applications is used to enhance performance and scalability, especially in applications that handle high volumes of traffic. The following are the key reasons why implementing asynchronous operations with in-memory caching is beneficial:

  • Non-Blocking I/O Operations: Asynchronous operations allow Web API to handle more requests simultaneously by not blocking the thread while waiting for I/O operations to complete. When it comes to caching, data retrieval, and storage can be made asynchronous, meaning the API does not have to wait for these operations to complete, thereby freeing up system resources to handle other requests.
  • Enhanced Performance: Asynchronous Caching leads to improved application performance. While synchronous cache operations lock resources or delay processing, asynchronous operations help manage concurrency better, making the system more responsive.
  • Efficiency in Resource Utilization: Asynchronous caching uses system resources more efficiently by not blocking threads during cache operations. These threads can then be used to handle other incoming requests or background tasks. It also reduces the overhead on the web server, as threads are not idly waiting for operations to complete.

Implementing Asynchronous In-Memory Caching in an ASP.NET Core Web API

Let us proceed and understand how to implement Asynchronous In-Memory Caching in our example.

Modifying ICustomCache for Asynchronous Operations:

Here, we are using the return type as Task or Task<T>, which indicates these are asynchronous methods. Also, we have added the word Async with all methods to indicate these are asynchronous methods (this is a good design approach; it is not mandatory; you can give any name). So, modify the ICustomCache.cs class file as follows:

using Microsoft.Extensions.Caching.Memory;
namespace InMemoryCachingDemo.Data
{
    public interface ICustomCache
    {
        Task<T> GetAsync<T>(string key);
        Task SetAsync<T>(string key, T value, TimeSpan? absoluteExpireTime = null, TimeSpan? slidingExpiration = null, CacheItemPriority priority = CacheItemPriority.Normal);
        Task<IDictionary<string, object>> GetAllCachesAsync();
        Task RemoveAsync(string key);
        Task ClearAsync();
    }
}
Implementing the Asynchronous Cache Operations:

Next, we need to modify the methods of the CustomCache class to support asynchronous operations. You can use Task and Task<T> as the return type of the methods. So, modify the CustomCache.cs class file as follows:

using Microsoft.Extensions.Caching.Memory;

namespace InMemoryCachingDemo.Data
{
    public class CustomCache : ICustomCache
    {
        private readonly IMemoryCache _memoryCache;
        private readonly HashSet<string> _keys = new HashSet<string>();

        public CustomCache(IMemoryCache memoryCache)
        {
            _memoryCache = memoryCache;
        }

        // Asynchronous method to retrieve a value from the Cache using a Key
        public async Task<T> GetAsync<T>(string key)
        {
            return await Task.Run(() =>
            {
                _memoryCache.TryGetValue(key, out T value);
                return value;
            });
        }

        // Asynchronous method to store a Key-value in the Cache
        public async Task SetAsync<T>(string key, T value, TimeSpan? absoluteExpireTime = null, TimeSpan? slidingExpiration = null, CacheItemPriority priority = CacheItemPriority.Normal)
        {
            await Task.Run(() =>
            {
                var _options = new MemoryCacheEntryOptions
                {
                    AbsoluteExpirationRelativeToNow = absoluteExpireTime,
                    SlidingExpiration = slidingExpiration,
                    Priority = priority
                };
                _memoryCache.Set(key, value, _options);
                _keys.Add(key);
            });
        }

        // Asynchronous method to fetch all cache keys and values
        public async Task<IDictionary<string, object>> GetAllCachesAsync()
        {
            return await Task.Run(() =>
            {
                var allItems = new Dictionary<string, object>();
                foreach (var key in _keys.ToList()) // ToList to avoid collection modification issues
                {
                    if (_memoryCache.TryGetValue(key, out object value))
                    {
                        allItems[key] = value;
                    }
                    else
                    {
                        // Item might have expired and removed, clean up keys
                        _keys.Remove(key);
                    }
                }
                return allItems;
            });
        }

        // Asynchronous method to remove Cache by Key
        public async Task RemoveAsync(string key)
        {
            await Task.Run(() =>
            {
                _memoryCache.Remove(key);
                _keys.Remove(key);
            });
        }

        // Asynchronous method to clear all the Cache
        public async Task ClearAsync()
        {
            await Task.Run(() =>
            {
                if (_memoryCache is MemoryCache concreteMemoryCache)
                {
                    concreteMemoryCache.Clear();
                }
                _keys.Clear();
            });
        }
    }
}

Note: Here, I used Task.Run to simulate asynchronous execution. In real-time application development, this is not recommended for making synchronous I/O-bound methods asynchronous since it simply offloads the work to a thread pool thread, which can be more costly than performing the operation synchronously.

Modifying the Location Repository:

Next, modify the LocationRepository.cs class file as follows to use the Asynchronous Caching:

using InMemoryCachingDemo.Data;
using InMemoryCachingDemo.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory;

namespace InMemoryCachingDemo.Repository
{
    public class LocationRepository
    {
        private readonly ApplicationDbContext _context;
        private readonly ICustomCache _cache;

        public LocationRepository(ApplicationDbContext context, ICustomCache cache)
        {
            _context = context;
            _cache = cache;
        }

        //Using Manual Eviction and High Priority for countries
        public async Task<List<Country>> GetCountriesAsync()
        {
            var cacheKey = "Countries";

            var countries = await _cache.GetAsync<List<Country>>(cacheKey);
            if (countries == null)
            {
                countries = await _context.Countries.ToListAsync();
                if (countries != null)
                {
                    await _cache.SetAsync(key: cacheKey, value: countries, priority: CacheItemPriority.High);
                }
            }

            return countries ?? new List<Country>();
        }

        // This Methid can be called after updating or deleting country data.
        public void RemoveCountriesFromCache()
        {
            var cacheKey = "Countries";
            _cache.RemoveAsync(cacheKey);
        }

        public async Task UpdateCountry(Country updatedCountry)
        {
            _context.Countries.Update(updatedCountry);
            await _context.SaveChangesAsync();
            RemoveCountriesFromCache();
        }

        // Using Sliding Expiration and Normal Priority for States
        public async Task<List<State>> GetStatesAsync(int countryId)
        {
            string cacheKey = $"States_{countryId}";
            var states =await _cache.GetAsync<List<State>>(cacheKey);
            if (states == null)
            {
                states = await _context.States.Where(s => s.CountryId == countryId).ToListAsync();
                if (states != null)
                {
                   await _cache.SetAsync(key: cacheKey, value: states, slidingExpiration: TimeSpan.FromMinutes(30)); 
                }
            }
            return states;
        }

        // Using Absolute Expiration and Low Priority for Cities
        public async Task<List<City>> GetCitiesAsync(int stateId)
        {
            string cacheKey = $"Cities_{stateId}";
            var cities = await _cache.GetAsync<List<City>>(cacheKey);
            if (cities == null)
            {
                cities = await _context.Cities.Where(c => c.StateId == stateId).ToListAsync();
                if (cities != null)
                {
                    await _cache.SetAsync(key: cacheKey, value: cities, 
                        absoluteExpireTime: TimeSpan.FromMinutes(30), priority: CacheItemPriority.Low);
                }
            }
            return cities;
        }
    }
}
Modifying the Location Controller:

Next, modify the Location Controller as follows:

using InMemoryCachingDemo.Data;
using InMemoryCachingDemo.Models;
using InMemoryCachingDemo.Repository;
using Microsoft.AspNetCore.Mvc;

namespace InMemoryCachingDemo.Controllers
{
    [Route("api/[controller]")]
    [ApiController]
    public class LocationController : ControllerBase
    {
        private readonly LocationRepository _repository;
        private readonly ICustomCache _cache;

        public LocationController(LocationRepository repository, ICustomCache cache)
        {
            _repository = repository;
            _cache = cache;
        }

        [HttpGet("countries")]
        public async Task<IActionResult> GetCountries()
        {
            var countries = await _repository.GetCountriesAsync();
            return Ok(countries);
        }

        [HttpPut("countries/{id}")]
        public async Task<IActionResult> UpdateCountry(int id, Country country)
        {
            if (id != country.CountryId)
                return BadRequest();

            try
            {
                await _repository.UpdateCountry(country);
                return NoContent(); // Indicates success with no content to return
            }
            catch (Exception ex)
            {
                if (!CountryExists(id))
                    return NotFound();
                else
                {
                    var customResponse = new
                    {
                        Code = 500,
                        Message = "Internal Server Error",
                        // Do not expose the actual error to the client
                        ErrorMessage = ex.Message
                    };
                    return StatusCode(StatusCodes.Status500InternalServerError, customResponse);
                }
            }
        }

        private bool CountryExists(int id)
        {
            return _repository.GetCountriesAsync().Result.Any(e => e.CountryId == id);
        }

        [HttpGet("states/{countryId}")]
        public async Task<IActionResult> GetStates(int countryId)
        {
            var states = await _repository.GetStatesAsync(countryId);
            return Ok(states);
        }

        [HttpGet("cities/{stateId}")]
        public async Task<IActionResult> GetCities(int stateId)
        {
            var cities = await _repository.GetCitiesAsync(stateId);
            return Ok(cities);
        }

        [HttpGet("AllCaches")]
        public async Task<IActionResult> GetAllCaches()
        {
            var allCaches = await _cache.GetAllCachesAsync();
            return Ok(allCaches);
        }

        [HttpPost("ClearCache")]
        public async Task<IActionResult> ClearCache()
        {
            await _cache.ClearAsync();
            return Ok("All Cache Cleared");
        }
    }
}

Note: Before implementing this pattern, consider if the added complexity and overhead of asynchronous code are necessary for your application. The synchronous version might be more appropriate and performant if the operations are purely in memory and are already very fast.

In the next article, I will discuss how to Implement Redis Cache in an ASP.NET Core Web API Application with Examples. In this article, I explain how to Create a Custom In-Memory Cache in ASP.NET Core Web API to perform Get, Set, GetAll, Remove, and Clear operations with Examples. I hope you enjoy this article.

Leave a Reply

Your email address will not be published. Required fields are marked *