An in-memory cache service in ASP.NET Core should give application code a small, stable contract for loading, caching, and invalidating data. It should not copy every method from IMemoryCache or expose cache-entry options throughout the codebase.

This guide builds that contract on HybridCache. With no secondary distributed cache configured, HybridCache still stores data in the process through MemoryCache, while adding per-key stampede protection and useful cancellation behavior. The original .NET 7 AsyncLazy implementation is preserved later as historical material.

Decide what the cache service should own

A wrapper is useful when it protects an application boundary. It can enforce namespaced keys, accept a small policy type, pass cancellation to the data source, and provide one explicit invalidation operation. A wrapper that merely renames Get, Set, and Remove adds another abstraction without reducing mistakes.

Keep domain decisions outside the generic cache service. The product service should decide that a product key is products:v1:{id}, whether a missing product may be cached, and when a successful write makes the entry stale. The cache service should execute the agreed policy consistently.

  • Use stable, namespaced keys built from trusted identifiers.
  • Cache immutable projections instead of tracked entities or mutable collections.
  • Keep the database or remote API as the source of truth.
  • Do not hide source failures by returning stale data unless that behavior is an explicit requirement.

For the underlying expiration, size-limit, and eviction rules, see the related in-memory caching guide for ASP.NET Core. This article stays focused on the service boundary and its behavior.

Build an in-memory cache service in ASP.NET Core

Install Microsoft.Extensions.Caching.Hybrid. The package can use both local and distributed storage, but a distributed cache is optional. Without one, the primary store remains in-process memory.

dotnet add package Microsoft.Extensions.Caching.Hybrid

The application contract exposes only cache-aside loading and key-based removal. CachePolicy carries a bounded lifetime without leaking HybridCacheEntryOptions into every caller. The factory receives a cancellation token because database and HTTP operations should not continue blindly after all callers have abandoned the shared load.

public sealed record CachePolicy(TimeSpan Expiration);

public interface IAppCache
{
    ValueTask<T> GetOrCreateAsync<T>(
        string key,
        CachePolicy policy,
        Func<CancellationToken, ValueTask<T>> factory,
        CancellationToken cancellationToken = default);

    ValueTask RemoveAsync(
        string key,
        CancellationToken cancellationToken = default);
}

The implementation validates the key, maps the application policy to the underlying cache, and delegates concurrency coordination to HybridCache. Both expiration values are set to the same lifetime here so the policy remains predictable if a distributed secondary cache is added later.

using Microsoft.Extensions.Caching.Hybrid;

public sealed class AppCache(HybridCache cache) : IAppCache
{
    public ValueTask<T> GetOrCreateAsync<T>(
        string key,
        CachePolicy policy,
        Func<CancellationToken, ValueTask<T>> factory,
        CancellationToken cancellationToken = default)
    {
        ArgumentException.ThrowIfNullOrWhiteSpace(key);
        ArgumentNullException.ThrowIfNull(policy);
        ArgumentNullException.ThrowIfNull(factory);

        if (policy.Expiration <= TimeSpan.Zero)
        {
            throw new ArgumentOutOfRangeException(
                nameof(policy),
                "Cache expiration must be greater than zero.");
        }

        var options = new HybridCacheEntryOptions
        {
            Expiration = policy.Expiration,
            LocalCacheExpiration = policy.Expiration
        };

        return cache.GetOrCreateAsync(
            key,
            factory,
            options,
            cancellationToken: cancellationToken);
    }

    public ValueTask RemoveAsync(
        string key,
        CancellationToken cancellationToken = default)
    {
        ArgumentException.ThrowIfNullOrWhiteSpace(key);
        return cache.RemoveAsync(key, cancellationToken);
    }
}

Register HybridCache and the wrapper once for the application. Both services are safe to share because the wrapper holds no request-specific state. If the ownership rules behind these lifetimes are unclear, the ASP.NET Core dependency injection guide covers singleton safety and captive dependencies.

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddHybridCache(options =>
{
    options.MaximumKeyLength = 256;
    options.MaximumPayloadBytes = 1024 * 1024;
});

builder.Services.AddSingleton<IAppCache, AppCache>();

Entries that exceed these limits bypass caching; the limits do not cap total process memory. Keep key cardinality bounded, choose short lifetimes for high-volume data, and monitor the process rather than treating per-entry limits as a memory budget.

Use the service from application code

The domain-facing service owns the key format and expiration policy. A version segment such as v1 makes intentional key-shape changes easy to deploy without colliding with entries created by older code.

public sealed record ProductSummary(
    int Id,
    string Name,
    decimal Price);

public interface IProductRepository
{
    Task<ProductSummary?> FindAsync(
        int id,
        CancellationToken cancellationToken);

    Task UpdateAsync(
        ProductSummary product,
        CancellationToken cancellationToken);
}

public sealed class ProductCatalogService(
    IAppCache cache,
    IProductRepository repository)
{
    private static readonly CachePolicy ProductPolicy =
        new(TimeSpan.FromMinutes(10));

    private static string ProductKey(int id) =>
        $"products:v1:{id}";

    public ValueTask<ProductSummary?> GetAsync(
        int id,
        CancellationToken cancellationToken = default) =>
        cache.GetOrCreateAsync(
            ProductKey(id),
            ProductPolicy,
            async token =>
                await repository.FindAsync(id, token),
            cancellationToken);
}

This example caches null for ten minutes when a product does not exist. That is a deliberate negative-cache policy, not an accidental side effect. If new products must become visible immediately, either use a much shorter policy for misses or avoid caching a missing result.

Invalidate entries after successful writes

Expiration limits staleness, but it does not make a cached value fresh after an update. Write to the source of truth first, then remove the corresponding key. Removing it before the write succeeds can let another request refill the cache with the old value while the update is still in progress.

public async Task UpdateAsync(
    ProductSummary product,
    CancellationToken cancellationToken = default)
{
    await repository.UpdateAsync(product, cancellationToken);
    await cache.RemoveAsync(
        ProductKey(product.Id),
        cancellationToken);
}

This sequence is still eventually consistent. A read can race with the database update, especially across several application instances. If the application requires a stronger guarantee, design version checks or cross-node invalidation explicitly instead of assuming that a local cache lock creates a distributed consistency boundary.

Understand the guarantees and limits

  • Stampede protection is implemented. One HybridCache instance allows only one concurrent caller for a key to run the factory. Other callers in that instance await the same operation.
  • Cancellation is implemented. Each caller passes its token to HybridCache. The shared factory token is canceled only when every caller waiting for that operation has canceled.
  • Key eviction is implemented. RemoveAsync invalidates the key in the local primary cache and in a configured secondary cache. This sample does not expose tag invalidation.
  • Cross-server coordination is not implemented. Each server has a separate HybridCache instance and local memory store. Stampede protection and local invalidation do not become a cluster-wide lock or notification system.
  • Stale-on-error behavior is not implemented. A factory failure reaches the caller. The service does not silently return an expired value.
  • Application metrics are not implemented. Add bounded hit, miss, source-call, error, and latency telemetry based on the chosen observability stack. Do not log unrestricted cache keys because they can contain high-cardinality or sensitive identifiers.

These limits are part of the contract. A small wrapper becomes dangerous when callers assume it provides distributed consistency, automatic refresh, or stale-data recovery that the code never implemented.

Verify the cache-service contract

Verify behavior with a real HybridCache registration and a counting factory. The test below launches concurrent requests for one unique key, confirms that the factory runs once, removes the entry, and confirms that the next read reloads it. This is stronger evidence than comparing two response times in Postman.

using Microsoft.Extensions.DependencyInjection;
using Xunit;

public sealed class AppCacheTests
{
    [Fact]
    public async Task Concurrent_reads_share_one_load_and_remove_forces_reload()
    {
        var services = new ServiceCollection();
        services.AddLogging();
        services.AddHybridCache();
        services.AddSingleton<IAppCache, AppCache>();

        await using var provider = services.BuildServiceProvider();
        var cache = provider.GetRequiredService<IAppCache>();
        var key = $"tests:{Guid.NewGuid():N}";
        var policy = new CachePolicy(TimeSpan.FromMinutes(1));
        var factoryCalls = 0;

        async ValueTask<int> LoadAsync(CancellationToken token)
        {
            Interlocked.Increment(ref factoryCalls);
            await Task.Delay(50, token);
            return 42;
        }

        var reads = Enumerable.Range(0, 20)
            .Select(_ => cache.GetOrCreateAsync(
                key,
                policy,
                LoadAsync).AsTask());

        var values = await Task.WhenAll(reads);

        Assert.All(values, value => Assert.Equal(42, value));
        Assert.Equal(1, factoryCalls);

        await cache.RemoveAsync(key);
        await cache.GetOrCreateAsync(key, policy, LoadAsync);

        Assert.Equal(2, factoryCalls);
    }
}

Add separate tests for expiration, factory exceptions, and caller cancellation when those paths matter to the application. In a multi-server deployment, run an integration test against the real secondary cache and verify the accepted cross-node staleness; the single-process test above does not prove cluster-wide behavior.

Historical .NET 7 implementation

The original article targeted net7.0 and referenced Microsoft.Extensions.Caching.Memory version 7.0.0. Its class-library name, interfaces, method names, and code are preserved below as originally published. This historical implementation has not been re-tested with the current .NET SDK and is not the recommended production design.

Original AsyncLazy class

The original service used AsyncLazy<T> to defer the factory and share its task. Both constructors wrap work in Task.Run; that extra thread-pool scheduling is unnecessary for a naturally asynchronous database or HTTP operation, and the API has no cancellation token. This published snippet also uses CS.Services.MemoryCacheService while the remaining project uses CS.Services.CacheMemoryService; the mismatch is preserved and would need correction before compiling the historical code.

namespace CS.Services.MemoryCacheService
{
    public class AsyncLazy<T> : Lazy<Task<T>>
    {
        public AsyncLazy(Func<T> valueFactory) :
            base(() => Task.Run(valueFactory))
        { }
        public AsyncLazy(Func<Task<T>> taskFactory) :
            base(() => Task.Run(() => taskFactory()))
        { }
        public TaskAwaiter<T> GetAwaiter()
        {
            return Value.GetAwaiter();
        }
    }
}

Original MemoryCache extensions

The extension stored the lazy task as the cache value and used two cache lookups around entry creation. Preserve this code for the historical tutorial, but do not treat it as a documented single-flight guarantee for every race or failure path.

namespace CS.Services.CacheMemoryService.Extensions
{
    public static class MemoryCacheExtensions
    {
        public static Task<T> GetOrCreateAsyncLazy<T>(this IMemoryCache cache, object key,
            Func<Task<T>> LazyFactory, MemoryCacheEntryOptions options)
        {
            if (!cache.TryGetValue(key, out AsyncLazy<T> asyncLazy))
            {
                var entry = cache.CreateEntry(key);
                if (options != null) entry.SetOptions(options);
                var newAsyncLazy = new AsyncLazy<T>(LazyFactory);
                entry.Value = newAsyncLazy;
                entry.Dispose(); // Dispose inserts the entry in the cache
                if (!cache.TryGetValue(key, out asyncLazy)) asyncLazy = newAsyncLazy;
            }
            if (asyncLazy.Value.IsCompleted) return asyncLazy.Value;
            return asyncLazy.Value.ContinueWith(t => t,
                default, TaskContinuationOptions.RunContinuationsAsynchronously,
                TaskScheduler.Default).Unwrap();
        }
        public static Task<T> GetOrCreateAsyncLazy<T>(this IMemoryCache cache, object key,
            Func<Task<T>> LazyFactory, DateTimeOffset absoluteExpiration)
        {
            return cache.GetOrCreateAsyncLazy(key, LazyFactory,
                new MemoryCacheEntryOptions() { AbsoluteExpiration = absoluteExpiration });
        }
        public static Task<T> GetOrCreateAsyncLazy<T>(this IMemoryCache cache, object key,
            Func<Task<T>> LazyFactory, TimeSpan slidingExpiration)
        {
            return cache.GetOrCreateAsyncLazy(key, LazyFactory,
                new MemoryCacheEntryOptions() { SlidingExpiration = slidingExpiration });
        }
    }
}

Original cache-service interface

The original interface mirrored most underlying memory-cache operations. That makes callers depend on MemoryCacheEntryOptions and lets each feature choose unrelated caching rules, which is why the modern contract above is intentionally smaller.

namespace CS.Services.CacheMemoryService.Interfaces
{
    public interface ICSMemoryCacheService
    {
        object GetCache(object key);
        T GetCache<T>(object key);
        void RemoveCache(object key);
        T SetCache<T>(object key, T value, MemoryCacheEntryOptions options );
        T SetCache<T>(object key, T value, DateTimeOffset absoluteExpiration);
        T SetCache<T>(object key, T value, TimeSpan slidingExpiration);
        Task<T> GetOrCreateAsyncLazy<T>(object key, Func<Task<T>> factory, MemoryCacheEntryOptions options);
        Task<T> GetOrCreateAsyncLazy<T>(object key, Func<Task<T>> factory, DateTimeOffset absoluteExpiration);
        Task<T> GetOrCreateAsyncLazy<T>(object key, Func<Task<T>> factory, TimeSpan slidingExpiration);
    }
}

Original cache-service class

The class forwarded each operation to the injected IMemoryCache. It contained no key policy, observability, cancellation, distributed behavior, or documented failure policy.

namespace CS.Services.CacheMemoryService
{
    public class CSMemoryCacheService : ICSMemoryCacheService
    {
        private readonly IMemoryCache memoryCache;
        public CSMemoryCacheService(IMemoryCache memoryCache)
        {
            this.memoryCache = memoryCache;
        }
        public object GetCache(object key)
        {
            return memoryCache.Get(key);
        }
        public T GetCache<T>(object key)
        {
            return memoryCache.Get<T>(key);
        }
        public void RemoveCache(object key)
        {
            memoryCache.Remove(key);
        }
        public T SetCache<T>(object key, T value, MemoryCacheEntryOptions options)
        {
            return memoryCache.Set<T>(key, value, options);
        }
        public T SetCache<T>(object key, T value, DateTimeOffset absoluteExpiration)
        {
            return memoryCache.Set<T>(key, value, absoluteExpiration);
        }
        public T SetCache<T>(object key, T value, TimeSpan slidingExpiration)
        {
            using ICacheEntry entry = memoryCache.CreateEntry(key);
            entry.SetSlidingExpiration(slidingExpiration);
            entry.Value = value;
            return value;
        }
        public Task<T> GetOrCreateAsyncLazy<T>(object key, Func<Task<T>> factory, MemoryCacheEntryOptions options)
        {
            return memoryCache.GetOrCreateAsyncLazy(key, factory, options);
        }
        public Task<T> GetOrCreateAsyncLazy<T>(object key, Func<Task<T>> factory, DateTimeOffset absoluteExpiration)
        {
            return memoryCache.GetOrCreateAsyncLazy(key, factory, absoluteExpiration);
        }
        public Task<T> GetOrCreateAsyncLazy<T>(object key, Func<Task<T>> factory, TimeSpan slidingExpiration)
        {
            return memoryCache.GetOrCreateAsyncLazy(key, factory, slidingExpiration);
        }
    }
}

Original registration

The historical project registered both IMemoryCache and its singleton wrapper. The wrapper was stateless, but every future dependency added to a singleton would also need to be safe for the root lifetime.

namespace CS.Services.CacheMemoryService.Extensions
{
    public static class CSMemoryCacheServiceExtensions
    {
        public static IServiceCollection AddCSMemoryCacheService(this IServiceCollection services)
        {
            return services.AddSingleton<ICSMemoryCacheService, CSMemoryCacheService>();
        }
    }
}
builder.Services.AddMemoryCache();
builder.Services.AddCSMemoryCacheService();

Original controller usage

The controller below is preserved with its original method names and cache settings. It injects both the raw cache and the wrapper, retains the GelAllEmployees typo, and uses NeverRemove plus SetSize(1024) in the first action. Those details are historical, not recommendations.

namespace CS.MemoryCache.WebApi.Controllers
{
    [Route("api/[controller]")]
    [ApiController]
    public class EmployeeController : ControllerBase
    {
        private readonly IEmployeeService employeeService;
        private readonly IMemoryCache memoryCache;
        private readonly ICSMemoryCacheService cSMemoryCacheService;
        public EmployeeController(IMemoryCache memoryCache,
            IEmployeeService employeeService,
            ICSMemoryCacheService cSMemoryCacheService)
        {
            this.memoryCache = memoryCache;
            this.employeeService = employeeService;
            this.cSMemoryCacheService = cSMemoryCacheService;
        }
        [HttpGet("employees")]
        public async Task<IEnumerable<Employee>> GelAllEmployees()
        {
            IEnumerable<Employee> cacheEmployees;
            if (!memoryCache.TryGetValue("cacheEmployees", out cacheEmployees))
            {
                cacheEmployees = await employeeService.GetEmployees();
                var cacheEntryOptions = new MemoryCacheEntryOptions()
                    .SetSlidingExpiration(TimeSpan.FromSeconds(100))
                    .SetAbsoluteExpiration(TimeSpan.FromSeconds(200))
                    .SetPriority(CacheItemPriority.NeverRemove)
                    .SetSize(1024);
                memoryCache.Set("cacheEmployees", cacheEmployees, cacheEntryOptions);
            }
            memoryCache.TryGetValue("cacheEmployees", out cacheEmployees);
            return cacheEmployees;
        }
        [HttpGet("cache")]
        public async Task<IEnumerable<Employee>> GetEmployees()
        {
            var items = await cSMemoryCacheService.GetOrCreateAsyncLazy<IEnumerable<Employee>>("employees", async () =>
            {
                var employees = await employeeService.GetEmployees();
                return employees;
            }, new MemoryCacheEntryOptions { AbsoluteExpiration = DateTimeOffset.Now.AddMinutes(10) });
            return items;
        }
    }
}

The original in-memory cache service repository remains public as historical source material. Its net7.0 projects and package versions should be evaluated in their original environment; the repository is not presented as a current production baseline.

Production checklist

  • Keep the wrapper contract smaller than the underlying cache API.
  • Build keys from trusted identifiers and bound their cardinality.
  • Use immutable cached values and deliberate negative-cache rules.
  • Pass cancellation through to the real data source.
  • Invalidate a key only after its source update succeeds.
  • Document that stampede protection is per cache instance, not cluster-wide.
  • Test concurrent reads, removal, expiration, cancellation, and factory failures.
  • Add bounded telemetry before claiming production observability.

A useful cache service is not a second copy of IMemoryCache. It is a narrow application boundary that makes keys, lifetimes, loading, cancellation, and invalidation predictable while leaving domain freshness decisions with the code that owns the data.

References

Found this useful? Support more practical developer content.

Author

Practical .NET, Angular, Azure, Blazor, and AI engineering for real-world development.

Ads Blocker Image Powered by Code Help Pro

Ads Blocker Detected!!!

We have detected that you are using extensions to block ads. Please support us by disabling these ads blocker.

Powered By
100% Free SEO Tools - Tool Kits PRO