Saturday, 5 July 2025

Quick Look: Clean In-Memory Caching Wrapper in .NET

Caching expensive operations—like database queries or external API calls—is one of the easiest ways to improve performance in .NET applications. While IMemoryCache from Microsoft.Extensions.Caching.Memory is easy to use out of the box, boilerplate setup often litters repository and service classes.

Here is a quick look at a simple, reusable CacheService wrapper that leverages C# primary constructors and attributes to make caching clean and declarative.

The Code

using System.Runtime.CompilerServices;
using Microsoft.Extensions.Caching.Memory;

namespace UWAuth.Services;

/// <summary>
/// Implements a service for caching data using <see cref="IMemoryCache"/>.
/// </summary>
public class CacheService(IMemoryCache cache)
{
    private const int CacheDuration = 5;

    /// <summary>
    /// Gets the cached data for the specified key or fetches it using the provided fetch task if not cached.
    /// </summary>
    /// <typeparam name="T">The type of the data to cache.</typeparam>
    /// <param name="fetchTask">The task to fetch the data if not cached.</param>
    /// <param name="customCacheDuration">The custom cache duration in minutes. Default is 5 minutes.</param>
    /// <param name="key">The cache key. Default is the caller member name.</param>
    /// <returns>The cached or fetched data.</returns>
    /// <exception cref="InvalidOperationException">Thrown if the cached data cannot be cast or deserialized.</exception>
    public async Task<T> GetOrFetch<T>(
        Func<Task<T>> fetchTask, 
        int customCacheDuration = CacheDuration, 
        [CallerMemberName] string key = "") 
        where T : class?
    {
        if (cache.TryGetValue(key, out var cached))
        {
            return cached as T ?? throw new InvalidOperationException($"Failed to deserialize cached items for {key}");
        }

        var items = await fetchTask();
        if (items is null)
        {
            return null!;
        }

        cache.Set(key, items, TimeSpan.FromMinutes(customCacheDuration));
        return items;
    }

    /// <summary>
    /// Invalidates the cached data for the specified key.
    /// </summary>
    /// <param name="key">The cache key.</param>
    /// <void>Invalidates the cache entry.</void>
    public void Invalidate(string key) => cache.Remove(key);
}

Key Highlights & Features

  1. Modern C# Primary Constructor Syntax: By defining public class CacheService(IMemoryCache cache), the IMemoryCache dependency is injected directly into the class header—eliminating private field declarations and boilerplate constructor assignments.

  2. Implicit Cache Keys via [CallerMemberName]: Instead of manually defining string keys like "GetUserProfile", the [CallerMemberName] attribute automatically captures the name of the calling method or property. If called inside GetUserInfo(), the key automatically becomes "GetUserInfo".

  3. Get-or-Fetch Pattern: The GetOrFetch<T> method handles the standard checking pattern:

    • Cache Hit: Checks TryGetValue. If found, safely casts and returns the cached object.

    • Cache Miss: Runs the fetchTask delegate, saves the result in IMemoryCache using TimeSpan.FromMinutes(), and returns the fresh data.

  4. Invalidation: Includes a clean Invalidate(string key) helper for manual cache purging when underlying data is updated or deleted.

How to Use It in Your Code

After registering IMemoryCache and CacheService in your DI container (builder.Services.AddMemoryCache()), you can inject CacheService anywhere:

public class UserService(CacheService cacheService, IUserDbContext dbContext)
{
    public async Task<List<User>?> GetActiveUsers()
    {
        // Cache key automatically defaults to "GetActiveUsers" via [CallerMemberName]
        return await cacheService.GetOrFetch(async () => 
        {
            return await dbContext.Users.Where(u => u.IsActive).ToListAsync();
        }, customCacheDuration: 10);
    }

    public void PurgeActiveUserCache()
    {
        cacheService.Invalidate(nameof(GetActiveUsers));
    }
}

No comments:

Post a Comment