Using caching in Asp.Net Core Minimal APIs

Caching provides a better customer experience and makes applications performant and scalable. If the result (query, page, etc.) is cached, the next time the same request comes, the cached data can be fetched instead of processing the request again.

This blog post will discuss the caching of data using IMemoryCache and IDistributedCache.

What is cache?

Caching is the technique of storing the data which are frequently used. Those data can be returned faster in the future or subsequent requests by eliminating unnecessary requests to external data sources.

Caching will be a good option when the databases take time to respond, when the CPU spikes occur during peak traffic or when server response times become inconsistent depending on the number of concurrent requests.

What is in-memory caching?

In-Memory caching refers to caching the computation results in the memory of the machine where the actual application is running. If the machine is restarted, the cache store will be flushed. It might be helpful to use this in the early stages of development or on the development machine.

IMemoryCache

The interface IMemoryCache can be injected into the endpoints. This injected instance can be used to see if the data is already cached and to add a new one to the cache.

While it requires a key, the cache options, such as the item's expiration, are optional. The expiration can be sliding or absolute.

Absolute expiration means no matter the frequency of accessing cached data, it will be removed after a fixed time.

Sliding expiration provides a way to remove the cached data which are not frequently accessed. If an object's sliding expiration of 30 seconds is enabled, it will expire only if the data was not accessed in the last 30 seconds.

The following code shows the methods and extension methods of this interface.

 public interface IMemoryCache: IDisposable
 {
        ICacheEntry CreateEntry(object key);
        MemoryCacheStatistics? GetCurrentStatistics();
        void Remove(object key);
        bool TryGetValue(object key, out object? value);
 }

 public static class CacheExtensions
 {
        public static object? Get(this IMemoryCache cache, object key);
        public static TItem? Get<TItem>(this IMemoryCache cache, object key);
        public static TItem? GetOrCreate<TItem>(this IMemoryCache cache, object key, Func<ICacheEntry, TItem> factory);
       public static Task<TItem?> GetOrCreateAsync<TItem>(this IMemoryCache cache, object key, Func<ICacheEntry, Task<TItem>> factory);
        public static TItem Set<TItem>(this IMemoryCache cache, object key, TItem value);
        public static TItem Set<TItem>(this IMemoryCache cache, object key, TItem value, DateTimeOffset absoluteExpiration);
        public static TItem Set<TItem>(this IMemoryCache cache, object key, TItem value, TimeSpan absoluteExpirationRelativeToNow);
        public static TItem Set<TItem>(this IMemoryCache cache, object key, TItem value, IChangeToken expirationToken);
        public static TItem Set<TItem>(this IMemoryCache cache, object key, TItem value, MemoryCacheEntryOptions? options);
        public static bool TryGetValue<TItem>(this IMemoryCache cache, object key, out TItem? value);
  }

What is Distributed Cache?

As the name suggests, a distributed cache does not use a web server’s memory as a cache store. Multiple app servers share a distributed cache, typically maintained as an external service to the app servers that access it.

A distributed cache can improve the performance and scalability of an ASP.NET Core app, mainly when a cloud service or a server farm hosts the app. Some examples of Distribute Cache include SQL Server database, Azure Redis Cache, NCache, etc.

In this blog post, we will use the SQL Server database.

IDistributedCache

The IDistributedCache interface includes synchronous and asynchronous methods. The interface allows items to be added, retrieved, and removed from the distributed cache implementation.

The interface provides similar functionality to IMemoryCache, but there are some differences, such as additional async methods, refresh methods and byte-based rather than object-based. The following code shows the methods and the extension methods of the interface.

 public interface IDistributedCache
    {
        byte[]? Get(string key);
        Task<byte[]?> GetAsync(string key, CancellationToken token = default);
        void Refresh(string key);
        Task RefreshAsync(string key, CancellationToken token = default);
        void Remove(string key);
        Task RemoveAsync(string key, CancellationToken token = default);
        void Set(string key, byte[] value, DistributedCacheEntryOptions options);
        Task SetAsync(string key, byte[] value, DistributedCacheEntryOptions options, CancellationToken token = default);
    }

public static class DistributedCacheExtensions
    {
        public static string? GetString(this IDistributedCache cache, string key);
        public static Task<string?> GetStringAsync(this IDistributedCache cache, string key, CancellationToken token = default);
        public static void Set(this IDistributedCache cache, string key, byte[] value);
        public static Task SetAsync(this IDistributedCache cache, string key, byte[] value, CancellationToken token = default);
        public static void SetString(this IDistributedCache cache, string key, string value);
        public static void SetString(this IDistributedCache cache, string key, string value, DistributedCacheEntryOptions options);
        public static Task SetStringAsync(this IDistributedCache cache, string key, string value, CancellationToken token = default);
        public static Task SetStringAsync(this IDistributedCache cache, string key, string value, DistributedCacheEntryOptions options, CancellationToken token = default);
    }

Distributed SQL Server Cache

Distributed SQL server cache uses SQL Server as the backing store to store the cached data. A database and a table can be created in a SQL server instance. The connection string for this database can be provided to the application to connect to this data store.

To create a SQL Server cached item table in a SQL Server instance, we can use the sql-cache tool. The tool creates a table with the name and schema that we specify.

The sql-cache command needs the SQL Server instance (Data Source), database (Initial Catalog), schema (for example, dbo), and table name (for example, TestCache).

dotnet sql-cache create "Data Source=(localdb)\MSSQLLocalDB;Initial Catalog=DistCache;Integrated Security=True;" dbo TestCache

Implementation

In-memory caching

Before using the IMemoryCache in our code, we must register it in the application.

builder.Services.AddMemoryCache();

For this example, a new service was created that has a method to return all the Todo items. It is possible to inject the IMemoryCache into the constructor.

In the method GetAll, we are using the method GetOrCreateAsync which will return the data if the cache exists or create a new one with the results of the method from the DbContext.

The code uses SlidingExpirationand AbsoluteExpiration. When both are used, a logical OR is used to decide if the data should be marked as expired. The data is removed from the cache if either the sliding expiration interval or the absolute expiration time passes.

public class TodoService : ITodoService
    {
        private readonly string key = "get-todos";
        private readonly IMemoryCache _memoryCache;
        private readonly TodoDb _dbContext;

        public TodoService(IMemoryCache memoryCache, TodoDb dbContext)
        {
            _memoryCache = memoryCache;
            _dbContext = dbContext;
        }

        public async Task<List<Todo>> GetAll()
        {
            var todos = await _memoryCache.GetOrCreateAsync<List<Todo>>(key, async entry =>
            {
                entry.SetSlidingExpiration(TimeSpan.FromSeconds(10));
                entry.SetAbsoluteExpiration(TimeSpan.FromSeconds(20));

                return await _dbContext.Todos.ToListAsync();
            });

            return todos;
        }
    }

This is the endpoint code using the new service that was created.

app.MapGet("v{version:apiVersion}/todoitems", [Authorize] async (ITodoService todoService) =>
{
    logger.LogInformation("Getting todoitems");

    return await todoService.GetAll();
})
.WithApiVersionSet(versionSet)
.MapToApiVersion(version1);

Distributed Cache

Before coding, we must prepare the SQL database to store the cache. The two following CLI commands need to be executed. The first command is installing the tool, and the second is creating a table in the local database.

dotnet tool install --global dotnet-sql-cache
dotnet sql-cache create "Data Source=(localdb)\MSSQLLocalDB;Initial Catalog=DistCache;Integrated Security=True;" dbo TestCache

After executing the CLI command, the table TestCache must have the following structure.

After installing the nugget package Microsoft.Extensions.Caching.SqlServer we can register the cache.

builder.Services.AddDistributedSqlServerCache(options =>
{
    options.ConnectionString = builder.Configuration.GetConnectionString("DistCacheConnection");
    options.SchemaName = "dbo";
    options.TableName = "TestCache";
})

The connection string was added to the appsettings.json file.

  "ConnectionStrings": {
    "DistCacheConnection": "Data Source=(localdb)\\MSSQLLocalDB;Initial Catalog=DistCache;Integrated Security=True;"
  }

It is necessary to inject IDistributedCache into TodoService's constructor, and then we can use the instance to cache and restore the data from the cache.

public class TodoService : ITodoService
    {
        private readonly string key = "get-todos";
        private readonly IMemoryCache _memoryCache;
        private readonly TodoDb _dbContext;
        private readonly IDistributedCache _distributedCache;

        public TodoService(IMemoryCache memoryCache, TodoDb dbContext, IDistributedCache distributedCache)
        {
            _memoryCache = memoryCache;
            _dbContext = dbContext;
            _distributedCache = distributedCache;
        }

        public async Task<List<Todo>> GetAllWithDistributedCache()
        {
            //Get the cached data
            byte[] cachedData = await _distributedCache.GetAsync(key);

            //Deserialize the cached response if there are any
            if (cachedData != null)
            {
                var jsonData = System.Text.Encoding.UTF8.GetString(cachedData);
                var dataResult = JsonSerializer.Deserialize<List<Todo>>(jsonData);
                if (dataResult != null)
                {
                    return dataResult;
                }
            }

            var todos = await _dbContext.Todos.ToListAsync();

            // Serialize the response
            byte[] objectToCache = JsonSerializer.SerializeToUtf8Bytes(todos);
            var cacheEntryOptions = new DistributedCacheEntryOptions()
                .SetSlidingExpiration(TimeSpan.FromSeconds(10))
                .SetAbsoluteExpiration(TimeSpan.FromSeconds(30));

            // Cache it
            await _distributedCache.SetAsync(key, objectToCache, cacheEntryOptions);

            return todos;
        }
    }

This is the entry that is stored in the database. We can see both expiration dates, the SlidingExpirationInSeconds value and the serialized data.

Wrapping Up

This example shows the basic ways to introduce caching to an ASP.Net core app. Other solutions using a distributed cache are NCache and Redis, for which I will prepare one exclusive blog post.

You can find the complete code on my GitHub.