.NET 9 Hybrid Cache

In this article, we’ll delve into one of Microsoft’s latest innovations, currently in preview at the time of writing: the Hybrid Cache library. This new library enables you to manage local or distributed caches without external dependencies.

Caching has become a common strategy in application development to enhance speed and performance. The simplest and fastest form of caching is in-memory caching.

This works well until your system becomes distributed. For instance, in a microservices architecture with multiple instances of the same service, storing state (e.g., user state) in memory is not practical since it would only be accessible to a single application instance. In such scenarios, a distributed caching system, like Redis, is the solution.

Before Hybrid Cache

Before this library, Microsoft offered two interfaces: IMemoryCache and IDistributedCache (the first for in-memory cache), alternatively you could use third-party libraries, like NCache or EasyCaching.

The Hybrid Cache library provides both IMemoryCache (a Level 1, or L1, cache) and IDistributedCache (a Level 2, or L2, cache, which is optional). It also addresses challenges like the cache stampede problem—where multiple processes try to access a element in cache that has just been emptied for updating. Other benefits include the ability to group caches using tags, support for custom serialization, and metrics collection.

Installation and configuration

Let’s start with a simple Web API project in .NET 9 and improve the following controller, which returns a weather forecast.

C#
[ApiController]
[Route("[controller]")]
public class WeatherForecastController : ControllerBase
{

    private static readonly string[] Summaries = new[]
    {
        "Freezing", "Neutral", "Warm", "Hot"
    };


    [HttpGet(Name = "GetWeatherForecast")]
    public async Task<IEnumerable<WeatherForecast>> GetAsync()
    {
        return await GetForecastAsync();
    }

    //Service implementation
    private async Task<WeatherForecast[]> GetForecastAsync()
    {
        return Enumerable.Range(1, 5).Select(index => new WeatherForecast
        {
            Date = DateOnly.FromDateTime(DateTime.Now.AddDays(index)),
            TemperatureC = Random.Shared.Next(-20, 55),
            Summary = Summaries[Random.Shared.Next(Summaries.Length)]
        })
        .ToArray();
    }
}

Install the Hybrid Cache library from Visual Studio’s Package Manager:

C#
Install-Package Microsoft.Extensions.Caching.Hybrid -Version 1.0.0-preview1

Next, modify the Program.cs file to add the library to the Dependency Injection container:

C#
builder.Services.AddHybridCache();

By default, this uses an L1 (in-memory) cache. In a follow-up article, we’ll configure an L2 cache using Redis.

To add default configurations:

C#
builder.Services.AddHybridCache(options =>
{
    // Maximum size of cached items
    options.MaximumPayloadBytes = 1024 * 1024 * 10; // 10MB
    options.MaximumKeyLength = 512;

    // Default timeouts
    options.DefaultEntryOptions = new HybridCacheEntryOptions
    {
        Expiration = TimeSpan.FromMinutes(30),
        LocalCacheExpiration = TimeSpan.FromMinutes(30)
    };
});

At lines 4 and 5, we defined that the key used to store values must have a maximum length of 512 characters and a payload size of up to 10MB.

At line 8, we specified the expiration times for both Level 1 (in-memory) cache (LocalCacheExpiration) and Level 2 (distributed) cache (Expiration).

Get or Create

The GetOrCreateAsync method checks if a value exists in the cache. If not, it invokes the specified function to fetch, cache, and return the value.

Here’s how we can modify the GetAsync method adding GetOrCreateAsync:

C#
        private static readonly string[] Summaries = new[]
        {
            "Freezing", "Neutral", "Warm", "Hot"
        };

        private HybridCache _cache;

        public WeatherForecastController(
            HybridCache cache
            )
        {
            _cache = cache;
        }

        [HttpGet(Name = "GetWeatherForecastWithCache")]
        public async Task<IEnumerable<WeatherForecast>> GetAsync()
        {
            //Hybrid Cache options
            var options = new HybridCacheEntryOptions
            {
                Expiration = TimeSpan.FromHours(1),
                LocalCacheExpiration = TimeSpan.FromMinutes(30)
            };

            return await _cache
                .GetOrCreateAsync(
                    "weatherForecast",  // --> Cache item name
                    GetForecastAsync(), // --> Value
                    async (state, cancellationToken) => await state, // --> configuration of cancellation token
                    options // --> Cache options
                );
        }

        private async Task<WeatherForecast[]> GetForecastAsync()
        {
            return Enumerable.Range(1, 5).Select(index => new WeatherForecast
            {
                Date = DateOnly.FromDateTime(DateTime.Now.AddDays(index)),
                TemperatureC = Random.Shared.Next(-20, 55),
                Summary = Summaries[Random.Shared.Next(Summaries.Length)]
            })
            .ToArray();
        }
    }

At line 12, we injected the instance of Hybrid Cache using Dependency Injection. At line 19, we configured an object to describe the cache characteristics (such as its expiration duration). Finally, at line 25, we used the method mentioned earlier to interact with the cache.

Add to cache

To insert a value into the cache, you can use SetAsync:

C#

[HttpPut(Name = "location/{locationCode}")]
public async Task<IActionResult> ChangeLocationAsync(string locationCode)
{
    //Service implementation
    await ChangeMyLocationAsync(locationCode);

    var options = new HybridCacheEntryOptions
    {
        Expiration = TimeSpan.FromHours(1),
        LocalCacheExpiration = TimeSpan.FromMinutes(30)
    };

    await _cache.SetAsync(
        $"myLocationCode",
        locationCode,
        options
    );

    return Ok(new { result = "Location changed!" });
}

private async Task ChangeMyLocationAsync(string locationCode)
{
    //service implementation
    await Task.FromResult(() => { });
}

In this example, we simulated an endpoint that updates a hypothetical value representing the user’s location. In a real-world scenario, we would retrieve the user’s location from the cache before returning the weather forecast. If both Level 1 (L1) in-memory cache and Level 2 (L2) distributed cache are being used, the method would update both layers, overwriting the previous value.

Remove from cache

To remove a value from the cache, you can use RemoveAsync:

C#
[HttpDelete(Name = "location/{locationCode}")]
public async Task<IActionResult> RemoveLocationAsync(string locationCode)
{
    //Service implementation
    await RemoveMyLocationAsync(locationCode);

    await _cache.RemoveAsync("myLocationCode");

    return Ok(new { result = "Location removed!" });
}

If a cache does not exist, nothing will happen, and no exception will be thrown.

Tags for group management

Tags can group cache items for bulk operations. For instance:

C#
        [HttpGet(Name = "GetWeatherForecast")]
        public async Task<IEnumerable<WeatherForecast>> GetWithCacheAsync()
        {
            //Hybrid Cache options
            var options = new HybridCacheEntryOptions
            {
                Expiration = TimeSpan.FromHours(1),
                LocalCacheExpiration = TimeSpan.FromMinutes(30)
            };

            //get my location code from a service
            string mylocation = await GetMyLocationAsync();

            return await _cache
                .GetOrCreateAsync(
                    "weatherForecast",  // --> Cache item name
                    GetForecastAsync(), // --> Value
                    async (state, cancellationToken) => await state, // configuration of cancellation token
                    options, // --> Cache options
                    tags: ["weather", mylocation]
                );
        }

        [HttpPost(Name = "GetWeatherForecast/location/{locationCode}/reset")]
        public async Task<IActionResult> ResetWeatcherOfLocation(string locationCode)
        {
            //Remove all cache with locationCode
            await _cache.RemoveByTagAsync(locationCode);

            return Ok(new { results = "Location reset!" });
        }

In the first endpoint, we added the user’s country weather forecast and included tags. In the second endpoint, we removed all cached items associated with the tag linked to the country provided as a parameter.

How to configure distributed cache (L2)

To add Redis as an L2 cache, install the official library:

C#
Install-Package Microsoft.Extensions.Caching.StackExchangeRedis

Update Program.cs to configure Redis:

C#
// Add Redis
builder.Services.AddStackExchangeRedisCache(options =>
{
    options.Configuration = "RedisConnectionString";
});

// Add HybridCache - it will automatically use Redis as L2
builder.Services.AddHybridCache();

When Hybrid Cache detects the Redis configuration, it automatically enables L2 caching.

Supported Frameworks

Hybrid Cache supports .NET Core, .NET Framework >= 4.7.2 and .NET Standard 2.0

Conclusioni

In this article, we explored Microsoft’s new Hybrid Cache library for managing both L1 (local) and L2 (distributed) caches. While still in preview, its simplicity and functionality make it promising. The first official release is expected in the coming months.

Thank you for reading—see you next time!

Share this article
Shareable URL
Prev Post

SOLID Programming Principles

Next Post

Managing JWT Tokens with Polly

Leave a Reply

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

Read next

HashSet in .NET 9

What is a HashSet<T>? In .NET, a HashSet<T> is a collection that implements an unordered set of unique…

LINQ Extension Method

At the 2024 edition of Overnet’s WPC conference, I attended an insightful talk about LINQ extension…