diff --git a/framework/SimpleModule.Core/Caching/CacheEntryOptions.cs b/framework/SimpleModule.Core/Caching/CacheEntryOptions.cs
new file mode 100644
index 00000000..82850d52
--- /dev/null
+++ b/framework/SimpleModule.Core/Caching/CacheEntryOptions.cs
@@ -0,0 +1,41 @@
+namespace SimpleModule.Core.Caching;
+
+///
+/// Implementation-agnostic options describing how an entry should be retained in the cache.
+///
+public sealed class CacheEntryOptions
+{
+ ///
+ /// Lifetime relative to the time the entry is written. Mutually exclusive with
+ /// .
+ ///
+ public TimeSpan? AbsoluteExpirationRelativeToNow { get; init; }
+
+ ///
+ /// An absolute point in time at which the entry expires. Mutually exclusive with
+ /// .
+ ///
+ public DateTimeOffset? AbsoluteExpiration { get; init; }
+
+ ///
+ /// Sliding expiration window. The entry is evicted if it is not accessed within this window.
+ ///
+ public TimeSpan? SlidingExpiration { get; init; }
+
+ ///
+ /// Optional size hint, used by stores that enforce a size limit.
+ ///
+ public long? Size { get; init; }
+
+ ///
+ /// Creates options that expire after the supplied duration.
+ ///
+ public static CacheEntryOptions Expires(TimeSpan duration) =>
+ new() { AbsoluteExpirationRelativeToNow = duration };
+
+ ///
+ /// Creates options with a sliding expiration window.
+ ///
+ public static CacheEntryOptions Sliding(TimeSpan window) =>
+ new() { SlidingExpiration = window };
+}
diff --git a/framework/SimpleModule.Core/Caching/CacheKey.cs b/framework/SimpleModule.Core/Caching/CacheKey.cs
new file mode 100644
index 00000000..32d5f022
--- /dev/null
+++ b/framework/SimpleModule.Core/Caching/CacheKey.cs
@@ -0,0 +1,19 @@
+namespace SimpleModule.Core.Caching;
+
+///
+/// Helpers for composing consistent cache keys.
+///
+public static class CacheKey
+{
+ ///
+ /// Joins the supplied parts with :, skipping null or empty segments.
+ ///
+ ///
+ /// CacheKey.Compose("settings", scope.ToString(), userId, key);
+ ///
+ public static string Compose(params string?[] parts)
+ {
+ ArgumentNullException.ThrowIfNull(parts);
+ return string.Join(':', parts.Where(p => !string.IsNullOrEmpty(p)));
+ }
+}
diff --git a/framework/SimpleModule.Core/Caching/CacheResult.cs b/framework/SimpleModule.Core/Caching/CacheResult.cs
new file mode 100644
index 00000000..4fc81380
--- /dev/null
+++ b/framework/SimpleModule.Core/Caching/CacheResult.cs
@@ -0,0 +1,24 @@
+namespace SimpleModule.Core.Caching;
+
+///
+/// Result of a cache lookup. Distinguishes a miss from a hit that contains a
+/// value (negative caching).
+///
+/// The cached value type.
+public readonly record struct CacheResult(bool Hit, T? Value);
+
+///
+/// Non-generic helpers for constructing values.
+///
+public static class CacheResult
+{
+ ///
+ /// Creates a miss result for type .
+ ///
+ public static CacheResult Miss() => default;
+
+ ///
+ /// Creates a hit result with the supplied value (which may be ).
+ ///
+ public static CacheResult Hit(T? value) => new(true, value);
+}
diff --git a/framework/SimpleModule.Core/Caching/CacheStoreExtensions.cs b/framework/SimpleModule.Core/Caching/CacheStoreExtensions.cs
new file mode 100644
index 00000000..c9302126
--- /dev/null
+++ b/framework/SimpleModule.Core/Caching/CacheStoreExtensions.cs
@@ -0,0 +1,40 @@
+namespace SimpleModule.Core.Caching;
+
+///
+/// Convenience extensions over .
+///
+public static class CacheStoreExtensions
+{
+ ///
+ /// Returns a view over the store where every key is automatically prefixed with
+ /// (joined with :). Useful for module- or tenant-scoped
+ /// cache namespacing without forcing every call site to remember the prefix.
+ ///
+ public static ICacheStore WithPrefix(this ICacheStore store, string prefix)
+ {
+ ArgumentNullException.ThrowIfNull(store);
+ return new PrefixedCacheStore(store, prefix);
+ }
+
+ ///
+ /// Synchronous-style helper for the common pattern var v = await cache.GetOrCreateAsync(...)
+ /// where the factory is itself synchronous.
+ ///
+ public static ValueTask GetOrCreateAsync(
+ this ICacheStore store,
+ string key,
+ Func factory,
+ CacheEntryOptions? options = null,
+ CancellationToken cancellationToken = default
+ )
+ {
+ ArgumentNullException.ThrowIfNull(store);
+ ArgumentNullException.ThrowIfNull(factory);
+ return store.GetOrCreateAsync(
+ key,
+ _ => new ValueTask(factory()),
+ options,
+ cancellationToken
+ );
+ }
+}
diff --git a/framework/SimpleModule.Core/Caching/CachingServiceCollectionExtensions.cs b/framework/SimpleModule.Core/Caching/CachingServiceCollectionExtensions.cs
new file mode 100644
index 00000000..b692085c
--- /dev/null
+++ b/framework/SimpleModule.Core/Caching/CachingServiceCollectionExtensions.cs
@@ -0,0 +1,24 @@
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.DependencyInjection.Extensions;
+
+namespace SimpleModule.Core.Caching;
+
+///
+/// DI registration for the unified SimpleModule caching abstraction.
+///
+public static class CachingServiceCollectionExtensions
+{
+ ///
+ /// Registers with the default in-process
+ /// implementation, along with the underlying
+ /// . Safe to call
+ /// multiple times — registrations are added with TryAdd.
+ ///
+ public static IServiceCollection AddSimpleModuleCaching(this IServiceCollection services)
+ {
+ ArgumentNullException.ThrowIfNull(services);
+ services.AddMemoryCache();
+ services.TryAddSingleton();
+ return services;
+ }
+}
diff --git a/framework/SimpleModule.Core/Caching/ICacheStore.cs b/framework/SimpleModule.Core/Caching/ICacheStore.cs
new file mode 100644
index 00000000..8b7141a7
--- /dev/null
+++ b/framework/SimpleModule.Core/Caching/ICacheStore.cs
@@ -0,0 +1,55 @@
+namespace SimpleModule.Core.Caching;
+
+///
+/// Unified caching abstraction used across SimpleModule modules.
+///
+///
+/// The default registration is an in-process MemoryCacheStore backed by
+/// . The interface is intentionally
+/// async-first so that distributed implementations (Redis, etc.) can be plugged in without
+/// changing call sites.
+///
+public interface ICacheStore
+{
+ ///
+ /// Looks up an entry. Returns a that distinguishes a miss from a
+ /// hit containing a value (negative caching).
+ ///
+ ValueTask> TryGetAsync(
+ string key,
+ CancellationToken cancellationToken = default
+ );
+
+ ///
+ /// Writes an entry, replacing any existing value for .
+ ///
+ ValueTask SetAsync(
+ string key,
+ T? value,
+ CacheEntryOptions? options = null,
+ CancellationToken cancellationToken = default
+ );
+
+ ///
+ /// Returns the cached value for , invoking
+ /// to populate the cache on a miss. Implementations must guard against cache stampedes —
+ /// concurrent callers for the same key see invoked at most once.
+ ///
+ ValueTask GetOrCreateAsync(
+ string key,
+ Func> factory,
+ CacheEntryOptions? options = null,
+ CancellationToken cancellationToken = default
+ );
+
+ ///
+ /// Removes a single entry. No-op if the key is absent.
+ ///
+ ValueTask RemoveAsync(string key, CancellationToken cancellationToken = default);
+
+ ///
+ /// Removes every entry whose key starts with . Useful for
+ /// invalidating a logical group (e.g., all entries for a user, tenant, or module).
+ ///
+ ValueTask RemoveByPrefixAsync(string prefix, CancellationToken cancellationToken = default);
+}
diff --git a/framework/SimpleModule.Core/Caching/MemoryCacheStore.cs b/framework/SimpleModule.Core/Caching/MemoryCacheStore.cs
new file mode 100644
index 00000000..e32b6956
--- /dev/null
+++ b/framework/SimpleModule.Core/Caching/MemoryCacheStore.cs
@@ -0,0 +1,210 @@
+using System.Collections.Concurrent;
+using Microsoft.Extensions.Caching.Memory;
+
+namespace SimpleModule.Core.Caching;
+
+///
+/// In-process implementation backed by
+/// . Adds two capabilities on top of the raw memory cache:
+/// stampede-safe via per-key locking, and
+/// via a tracked key set.
+///
+public sealed class MemoryCacheStore : ICacheStore, IDisposable
+{
+ private readonly IMemoryCache _cache;
+ private readonly ConcurrentDictionary _trackedKeys = new(StringComparer.Ordinal);
+ private readonly ConcurrentDictionary _keyLocks = new(
+ StringComparer.Ordinal
+ );
+
+ public MemoryCacheStore(IMemoryCache cache)
+ {
+ ArgumentNullException.ThrowIfNull(cache);
+ _cache = cache;
+ }
+
+ public ValueTask> TryGetAsync(
+ string key,
+ CancellationToken cancellationToken = default
+ )
+ {
+ ArgumentException.ThrowIfNullOrEmpty(key);
+ cancellationToken.ThrowIfCancellationRequested();
+
+ if (_cache.TryGetValue(key, out var raw))
+ {
+ return ValueTask.FromResult(CacheResult.Hit((T?)raw));
+ }
+
+ return ValueTask.FromResult(CacheResult.Miss());
+ }
+
+ public ValueTask SetAsync(
+ string key,
+ T? value,
+ CacheEntryOptions? options = null,
+ CancellationToken cancellationToken = default
+ )
+ {
+ ArgumentException.ThrowIfNullOrEmpty(key);
+ cancellationToken.ThrowIfCancellationRequested();
+
+ SetCore(key, value, options);
+ return ValueTask.CompletedTask;
+ }
+
+ public async ValueTask GetOrCreateAsync(
+ string key,
+ Func> factory,
+ CacheEntryOptions? options = null,
+ CancellationToken cancellationToken = default
+ )
+ {
+ ArgumentException.ThrowIfNullOrEmpty(key);
+ ArgumentNullException.ThrowIfNull(factory);
+
+ if (_cache.TryGetValue(key, out var existing))
+ {
+ return (T?)existing;
+ }
+
+ var gate = _keyLocks.GetOrAdd(key, static _ => new SemaphoreSlim(1, 1));
+ await gate.WaitAsync(cancellationToken).ConfigureAwait(false);
+ try
+ {
+ if (_cache.TryGetValue(key, out existing))
+ {
+ return (T?)existing;
+ }
+
+ var value = await factory(cancellationToken).ConfigureAwait(false);
+ SetCore(key, value, options);
+ return value;
+ }
+ finally
+ {
+ gate.Release();
+ // Reclaim the lock entry once no other caller is waiting on it.
+ // CurrentCount == 1 means the semaphore is fully released and idle; any
+ // concurrent waiter would hold it below 1. This bounds the dictionary to
+ // keys currently being populated rather than every key ever populated.
+ if (gate.CurrentCount == 1 && _keyLocks.TryRemove(key, out var removed))
+ {
+ removed.Dispose();
+ }
+ }
+ }
+
+ public ValueTask RemoveAsync(string key, CancellationToken cancellationToken = default)
+ {
+ ArgumentException.ThrowIfNullOrEmpty(key);
+ cancellationToken.ThrowIfCancellationRequested();
+
+ _cache.Remove(key);
+ _trackedKeys.TryRemove(key, out _);
+ TryReclaimIdleLock(key);
+ return ValueTask.CompletedTask;
+ }
+
+ public ValueTask RemoveByPrefixAsync(
+ string prefix,
+ CancellationToken cancellationToken = default
+ )
+ {
+ ArgumentException.ThrowIfNullOrEmpty(prefix);
+ cancellationToken.ThrowIfCancellationRequested();
+
+ foreach (var key in _trackedKeys.Keys)
+ {
+ if (key.StartsWith(prefix, StringComparison.Ordinal))
+ {
+ _cache.Remove(key);
+ _trackedKeys.TryRemove(key, out _);
+ TryReclaimIdleLock(key);
+ }
+ }
+
+ return ValueTask.CompletedTask;
+ }
+
+ ///
+ /// Removes and disposes a per-key semaphore only when it is observably idle
+ /// (no waiters, fully released). If a caller
+ /// is still holding the gate, the lock is left in place and that caller's
+ /// own finally block will reclaim it after release.
+ ///
+ private void TryReclaimIdleLock(string key)
+ {
+ if (
+ _keyLocks.TryGetValue(key, out var gate)
+ && gate.CurrentCount == 1
+ && _keyLocks.TryRemove(key, out var removed)
+ )
+ {
+ removed.Dispose();
+ }
+ }
+
+ private void SetCore(string key, T? value, CacheEntryOptions? options)
+ {
+ using var entry = _cache.CreateEntry(key);
+ entry.Value = value;
+
+ if (options is not null)
+ {
+ if (options.AbsoluteExpirationRelativeToNow is { } relative)
+ {
+ entry.AbsoluteExpirationRelativeToNow = relative;
+ }
+
+ if (options.AbsoluteExpiration is { } absolute)
+ {
+ entry.AbsoluteExpiration = absolute;
+ }
+
+ if (options.SlidingExpiration is { } sliding)
+ {
+ entry.SlidingExpiration = sliding;
+ }
+
+ if (options.Size is { } size)
+ {
+ entry.Size = size;
+ }
+ }
+
+ // Track the key so RemoveByPrefixAsync can find it. The eviction callback
+ // releases both tracking entries and any idle per-key lock when the entry
+ // naturally expires or is evicted by memory pressure, so both sets stay bounded.
+ _trackedKeys[key] = 0;
+ entry.RegisterPostEvictionCallback(
+ static (evictedKey, _, _, state) =>
+ {
+ if (state is not MemoryCacheStore self || evictedKey is not string s)
+ {
+ return;
+ }
+
+ self._trackedKeys.TryRemove(s, out _);
+ if (
+ self._keyLocks.TryGetValue(s, out var gate)
+ && gate.CurrentCount == 1
+ && self._keyLocks.TryRemove(s, out var removed)
+ )
+ {
+ removed.Dispose();
+ }
+ },
+ this
+ );
+ }
+
+ public void Dispose()
+ {
+ foreach (var gate in _keyLocks.Values)
+ {
+ gate.Dispose();
+ }
+ _keyLocks.Clear();
+ }
+}
diff --git a/framework/SimpleModule.Core/Caching/PrefixedCacheStore.cs b/framework/SimpleModule.Core/Caching/PrefixedCacheStore.cs
new file mode 100644
index 00000000..5a10dd51
--- /dev/null
+++ b/framework/SimpleModule.Core/Caching/PrefixedCacheStore.cs
@@ -0,0 +1,48 @@
+namespace SimpleModule.Core.Caching;
+
+///
+/// Decorator that scopes every key with a fixed prefix before forwarding to the inner store.
+/// Created via .
+///
+internal sealed class PrefixedCacheStore : ICacheStore
+{
+ private readonly ICacheStore _inner;
+ private readonly string _prefix;
+
+ public PrefixedCacheStore(ICacheStore inner, string prefix)
+ {
+ ArgumentNullException.ThrowIfNull(inner);
+ ArgumentException.ThrowIfNullOrEmpty(prefix);
+ _inner = inner;
+ _prefix = prefix.EndsWith(':') ? prefix : prefix + ':';
+ }
+
+ private string Scope(string key) => _prefix + key;
+
+ public ValueTask> TryGetAsync(
+ string key,
+ CancellationToken cancellationToken = default
+ ) => _inner.TryGetAsync(Scope(key), cancellationToken);
+
+ public ValueTask SetAsync(
+ string key,
+ T? value,
+ CacheEntryOptions? options = null,
+ CancellationToken cancellationToken = default
+ ) => _inner.SetAsync(Scope(key), value, options, cancellationToken);
+
+ public ValueTask GetOrCreateAsync(
+ string key,
+ Func> factory,
+ CacheEntryOptions? options = null,
+ CancellationToken cancellationToken = default
+ ) => _inner.GetOrCreateAsync(Scope(key), factory, options, cancellationToken);
+
+ public ValueTask RemoveAsync(string key, CancellationToken cancellationToken = default) =>
+ _inner.RemoveAsync(Scope(key), cancellationToken);
+
+ public ValueTask RemoveByPrefixAsync(
+ string prefix,
+ CancellationToken cancellationToken = default
+ ) => _inner.RemoveByPrefixAsync(_prefix + prefix, cancellationToken);
+}
diff --git a/framework/SimpleModule.Hosting/SimpleModuleHostExtensions.cs b/framework/SimpleModule.Hosting/SimpleModuleHostExtensions.cs
index f1ca2614..0802cc09 100644
--- a/framework/SimpleModule.Hosting/SimpleModuleHostExtensions.cs
+++ b/framework/SimpleModule.Hosting/SimpleModuleHostExtensions.cs
@@ -8,6 +8,7 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Hosting;
+using SimpleModule.Core.Caching;
using SimpleModule.Core.Constants;
using SimpleModule.Core.Events;
using SimpleModule.Core.Exceptions;
@@ -70,6 +71,9 @@ public static WebApplicationBuilder AddSimpleModuleInfrastructure(
builder.Services.AddSingleton();
+ // Unified caching abstraction (ICacheStore) shared across all modules.
+ builder.Services.AddSimpleModuleCaching();
+
builder.Services.AddSingleton();
builder.Services.AddHostedService();
builder.Services.AddScoped();
diff --git a/framework/SimpleModule.Hosting/SimpleModuleWorkerExtensions.cs b/framework/SimpleModule.Hosting/SimpleModuleWorkerExtensions.cs
index f2f9b7d2..ccfe8908 100644
--- a/framework/SimpleModule.Hosting/SimpleModuleWorkerExtensions.cs
+++ b/framework/SimpleModule.Hosting/SimpleModuleWorkerExtensions.cs
@@ -3,6 +3,7 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
+using SimpleModule.Core.Caching;
using SimpleModule.Core.Events;
using SimpleModule.Database.Interceptors;
@@ -30,6 +31,7 @@ public static HostApplicationBuilder AddSimpleModuleWorker(this HostApplicationB
builder.Configuration["BackgroundJobs:WorkerMode"] = "Consumer";
// Core infrastructure that the worker needs:
+ builder.Services.AddSimpleModuleCaching();
builder.Services.AddSingleton();
builder.Services.AddHostedService();
builder.Services.AddScoped();
diff --git a/modules/FeatureFlags/src/SimpleModule.FeatureFlags/FeatureFlagService.cs b/modules/FeatureFlags/src/SimpleModule.FeatureFlags/FeatureFlagService.cs
index 9a8f1599..8a6ca460 100644
--- a/modules/FeatureFlags/src/SimpleModule.FeatureFlags/FeatureFlagService.cs
+++ b/modules/FeatureFlags/src/SimpleModule.FeatureFlags/FeatureFlagService.cs
@@ -1,7 +1,7 @@
using Microsoft.EntityFrameworkCore;
-using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
+using SimpleModule.Core.Caching;
using SimpleModule.Core.Entities;
using SimpleModule.Core.FeatureFlags;
using SimpleModule.FeatureFlags.Contracts;
@@ -12,7 +12,7 @@ namespace SimpleModule.FeatureFlags;
public sealed partial class FeatureFlagService(
FeatureFlagsDbContext db,
IFeatureFlagRegistry registry,
- IMemoryCache cache,
+ ICacheStore cache,
ILogger logger,
IServiceProvider serviceProvider
) : IFeatureFlagContracts, IFeatureFlagService
@@ -22,6 +22,9 @@ IServiceProvider serviceProvider
);
private static readonly TimeSpan CacheDuration = TimeSpan.FromSeconds(30);
private const string AllFlagDataCacheKey = "ff:all-data";
+ private const string FlagDataKeyPrefix = "ff:data:";
+
+ private static string FlagDataCacheKey(string flagName) => FlagDataKeyPrefix + flagName;
private sealed record FlagData(
bool IsEnabled,
@@ -125,7 +128,7 @@ UpdateFeatureFlagRequest request
}
await db.SaveChangesAsync();
- InvalidateCache(flagName);
+ await InvalidateCacheAsync(flagName);
LogFlagToggled(logger, flagName, request.IsEnabled);
@@ -177,7 +180,7 @@ SetOverrideRequest request
}
await db.SaveChangesAsync();
- InvalidateCache(flagName);
+ await InvalidateCacheAsync(flagName);
return new FeatureFlagOverride
{
@@ -196,78 +199,85 @@ public async Task DeleteOverrideAsync(int overrideId)
{
db.FeatureFlagOverrides.Remove(entity);
await db.SaveChangesAsync();
- InvalidateCache(entity.FlagName);
+ await InvalidateCacheAsync(entity.FlagName);
}
}
private async Task> GetAllFlagDataAsync()
{
- if (
- cache.TryGetValue(AllFlagDataCacheKey, out Dictionary? cached)
- && cached is not null
- )
- {
- return cached;
- }
-
- var definitions = registry.GetAllDefinitions();
- var flagNames = definitions.Select(d => d.Name).ToList();
-
- var dbFlags = await db
- .FeatureFlags.AsNoTracking()
- .Where(f => flagNames.Contains(f.Name))
- .ToDictionaryAsync(f => f.Name);
-
- var dbOverrides = await db
- .FeatureFlagOverrides.AsNoTracking()
- .Where(o => flagNames.Contains(o.FlagName))
- .ToListAsync();
-
- var overridesByFlag = dbOverrides
- .GroupBy(o => o.FlagName)
- .ToDictionary(g => g.Key, g => g.ToList());
-
- var allData = new Dictionary(flagNames.Count, StringComparer.Ordinal);
- foreach (var def in definitions)
- {
- var isEnabled = dbFlags.TryGetValue(def.Name, out var entity)
- ? entity.IsEnabled
- : def.DefaultEnabled;
-
- var flagOverrides = overridesByFlag.GetValueOrDefault(def.Name, []);
- var data = BuildFlagData(isEnabled, flagOverrides);
-
- allData[def.Name] = data;
- cache.Set($"ff:data:{def.Name}", data, CacheDuration);
- }
+ var result = await cache.GetOrCreateAsync>(
+ AllFlagDataCacheKey,
+ async ct =>
+ {
+ var definitions = registry.GetAllDefinitions();
+ var flagNames = definitions.Select(d => d.Name).ToList();
+
+ var dbFlags = await db
+ .FeatureFlags.AsNoTracking()
+ .Where(f => flagNames.Contains(f.Name))
+ .ToDictionaryAsync(f => f.Name, ct);
+
+ var dbOverrides = await db
+ .FeatureFlagOverrides.AsNoTracking()
+ .Where(o => flagNames.Contains(o.FlagName))
+ .ToListAsync(ct);
+
+ var overridesByFlag = dbOverrides
+ .GroupBy(o => o.FlagName)
+ .ToDictionary(g => g.Key, g => g.ToList());
+
+ var allData = new Dictionary(
+ flagNames.Count,
+ StringComparer.Ordinal
+ );
+ foreach (var def in definitions)
+ {
+ var isEnabled = dbFlags.TryGetValue(def.Name, out var entity)
+ ? entity.IsEnabled
+ : def.DefaultEnabled;
+
+ var flagOverrides = overridesByFlag.GetValueOrDefault(def.Name, []);
+ var data = BuildFlagData(isEnabled, flagOverrides);
+
+ allData[def.Name] = data;
+ await cache.SetAsync(
+ FlagDataCacheKey(def.Name),
+ data,
+ CacheEntryOptions.Expires(CacheDuration),
+ ct
+ );
+ }
- cache.Set(AllFlagDataCacheKey, allData, CacheDuration);
- return allData;
+ return allData;
+ },
+ CacheEntryOptions.Expires(CacheDuration)
+ );
+ return result ?? [];
}
private async Task GetFlagDataAsync(string flagName)
{
- var cacheKey = $"ff:data:{flagName}";
- if (cache.TryGetValue(cacheKey, out FlagData? cached) && cached is not null)
- {
- return cached;
- }
-
- var flag = await db
- .FeatureFlags.AsNoTracking()
- .FirstOrDefaultAsync(f => f.Name == flagName);
+ var result = await cache.GetOrCreateAsync(
+ FlagDataCacheKey(flagName),
+ async ct =>
+ {
+ var flag = await db
+ .FeatureFlags.AsNoTracking()
+ .FirstOrDefaultAsync(f => f.Name == flagName, ct);
- var isEnabled =
- flag?.IsEnabled ?? registry.GetDefinition(flagName)?.DefaultEnabled ?? false;
+ var isEnabled =
+ flag?.IsEnabled ?? registry.GetDefinition(flagName)?.DefaultEnabled ?? false;
- var overrides = await db
- .FeatureFlagOverrides.AsNoTracking()
- .Where(o => o.FlagName == flagName)
- .ToListAsync();
+ var overrides = await db
+ .FeatureFlagOverrides.AsNoTracking()
+ .Where(o => o.FlagName == flagName)
+ .ToListAsync(ct);
- var data = BuildFlagData(isEnabled, overrides);
- cache.Set(cacheKey, data, CacheDuration);
- return data;
+ return BuildFlagData(isEnabled, overrides);
+ },
+ CacheEntryOptions.Expires(CacheDuration)
+ );
+ return result ?? BuildFlagData(false, []);
}
private static FlagData BuildFlagData(bool isEnabled, List overrides)
@@ -338,10 +348,10 @@ private static FeatureFlag ToDto(FeatureFlagDefinition? def, FeatureFlagEntity?
};
}
- private void InvalidateCache(string flagName)
+ private async ValueTask InvalidateCacheAsync(string flagName)
{
- cache.Remove($"ff:data:{flagName}");
- cache.Remove(AllFlagDataCacheKey);
+ await cache.RemoveAsync(FlagDataCacheKey(flagName));
+ await cache.RemoveAsync(AllFlagDataCacheKey);
}
[LoggerMessage(
diff --git a/modules/FeatureFlags/src/SimpleModule.FeatureFlags/FeatureFlagsModule.cs b/modules/FeatureFlags/src/SimpleModule.FeatureFlags/FeatureFlagsModule.cs
index ad86fa63..3b10fd4e 100644
--- a/modules/FeatureFlags/src/SimpleModule.FeatureFlags/FeatureFlagsModule.cs
+++ b/modules/FeatureFlags/src/SimpleModule.FeatureFlags/FeatureFlagsModule.cs
@@ -17,7 +17,6 @@ public class FeatureFlagsModule : IModule
{
public void ConfigureServices(IServiceCollection services, IConfiguration configuration)
{
- services.AddMemoryCache();
services.AddModuleDbContext(
configuration,
FeatureFlagsConstants.ModuleName
diff --git a/modules/FeatureFlags/tests/SimpleModule.FeatureFlags.Tests/Unit/FeatureFlagServiceTests.cs b/modules/FeatureFlags/tests/SimpleModule.FeatureFlags.Tests/Unit/FeatureFlagServiceTests.cs
index 4e9d0b11..42c87f14 100644
--- a/modules/FeatureFlags/tests/SimpleModule.FeatureFlags.Tests/Unit/FeatureFlagServiceTests.cs
+++ b/modules/FeatureFlags/tests/SimpleModule.FeatureFlags.Tests/Unit/FeatureFlagServiceTests.cs
@@ -4,6 +4,7 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
+using SimpleModule.Core.Caching;
using SimpleModule.Core.FeatureFlags;
using SimpleModule.Database;
using SimpleModule.FeatureFlags;
@@ -18,7 +19,9 @@ public sealed class FeatureFlagServiceTests : IDisposable
private readonly FeatureFlagService _sut;
private readonly IFeatureFlagRegistry _registry;
private readonly MemoryCache _cache;
+ private readonly MemoryCacheStore _cacheStore;
private readonly List _freshCaches = [];
+ private readonly List _freshCacheStores = [];
public FeatureFlagServiceTests()
{
@@ -51,10 +54,11 @@ public FeatureFlagServiceTests()
_registry = builder.Build();
_cache = new MemoryCache(Options.Create(new MemoryCacheOptions()));
+ _cacheStore = new MemoryCacheStore(_cache);
_sut = new FeatureFlagService(
_db,
_registry,
- _cache,
+ _cacheStore,
NullLogger.Instance,
new ServiceCollection().BuildServiceProvider()
);
@@ -62,11 +66,17 @@ public FeatureFlagServiceTests()
public void Dispose()
{
+ foreach (var s in _freshCacheStores)
+ {
+ s.Dispose();
+ }
+
foreach (var c in _freshCaches)
{
c.Dispose();
}
+ _cacheStore.Dispose();
_cache.Dispose();
_db.Dispose();
}
@@ -76,10 +86,12 @@ private FeatureFlagService CreateFreshService()
// Create a new cache to bypass cached results
var freshCache = new MemoryCache(Options.Create(new MemoryCacheOptions()));
_freshCaches.Add(freshCache);
+ var freshStore = new MemoryCacheStore(freshCache);
+ _freshCacheStores.Add(freshStore);
return new FeatureFlagService(
_db,
_registry,
- freshCache,
+ freshStore,
NullLogger.Instance,
new ServiceCollection().BuildServiceProvider()
);
diff --git a/modules/Localization/src/SimpleModule.Localization/LocalizationModule.cs b/modules/Localization/src/SimpleModule.Localization/LocalizationModule.cs
index 506d236e..d95c8c82 100644
--- a/modules/Localization/src/SimpleModule.Localization/LocalizationModule.cs
+++ b/modules/Localization/src/SimpleModule.Localization/LocalizationModule.cs
@@ -13,7 +13,6 @@ public sealed class LocalizationModule : IModule
{
public void ConfigureServices(IServiceCollection services, IConfiguration configuration)
{
- services.AddMemoryCache();
services.AddSingleton();
services.AddLocalization();
services.AddSingleton();
diff --git a/modules/Localization/src/SimpleModule.Localization/Middleware/LocaleResolutionMiddleware.cs b/modules/Localization/src/SimpleModule.Localization/Middleware/LocaleResolutionMiddleware.cs
index c443932b..fd1d0d54 100644
--- a/modules/Localization/src/SimpleModule.Localization/Middleware/LocaleResolutionMiddleware.cs
+++ b/modules/Localization/src/SimpleModule.Localization/Middleware/LocaleResolutionMiddleware.cs
@@ -1,9 +1,9 @@
using System.Globalization;
using System.Security.Claims;
using Microsoft.AspNetCore.Http;
-using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
+using SimpleModule.Core.Caching;
using SimpleModule.Core.Inertia;
using SimpleModule.Core.Settings;
using SimpleModule.Localization.Contracts;
@@ -16,11 +16,14 @@ public sealed class LocaleResolutionMiddleware(
RequestDelegate next,
IConfiguration configuration,
TranslationLoader loader,
- IMemoryCache cache
+ ICacheStore cache
)
{
- private static readonly TimeSpan UserLocaleCacheDuration = TimeSpan.FromMinutes(5);
- private static readonly TimeSpan AcceptLanguageCacheDuration = TimeSpan.FromMinutes(30);
+ private static readonly CacheEntryOptions UserLocaleCacheOptions = CacheEntryOptions.Expires(
+ TimeSpan.FromMinutes(5)
+ );
+ private static readonly CacheEntryOptions AcceptLanguageCacheOptions =
+ CacheEntryOptions.Expires(TimeSpan.FromMinutes(30));
public async Task InvokeAsync(HttpContext context)
{
@@ -62,9 +65,10 @@ private async Task ResolveLocaleAsync(HttpContext context)
// This avoids cross-browser cache pollution where one browser's Accept-Language
// leaks to another browser for the same user.
var cacheKey = UserLocaleKey(userId);
- if (cache.TryGetValue(cacheKey, out string? cachedLocale) && cachedLocale is not null)
+ var cachedHit = await cache.TryGetAsync(cacheKey);
+ if (cachedHit.Hit && !string.IsNullOrEmpty(cachedHit.Value))
{
- return cachedLocale;
+ return cachedHit.Value;
}
var settings = context.RequestServices.GetService();
@@ -77,17 +81,17 @@ private async Task ResolveLocaleAsync(HttpContext context)
);
if (!string.IsNullOrEmpty(userLocale))
{
- cache.Set(cacheKey, userLocale, UserLocaleCacheDuration);
+ await cache.SetAsync(cacheKey, userLocale, UserLocaleCacheOptions);
return userLocale;
}
}
}
// No explicit user setting — resolve from Accept-Language header or default
- return ResolveFromAcceptLanguage(context);
+ return await ResolveFromAcceptLanguageAsync(context);
}
- private string ResolveFromAcceptLanguage(HttpContext context)
+ private async Task ResolveFromAcceptLanguageAsync(HttpContext context)
{
var rawHeader = context.Request.Headers.AcceptLanguage.ToString();
if (string.IsNullOrEmpty(rawHeader))
@@ -96,9 +100,10 @@ private string ResolveFromAcceptLanguage(HttpContext context)
}
var cacheKey = AcceptLanguageKey(rawHeader);
- if (cache.TryGetValue(cacheKey, out string? cached) && cached is not null)
+ var cachedHit = await cache.TryGetAsync(cacheKey);
+ if (cachedHit.Hit && !string.IsNullOrEmpty(cachedHit.Value))
{
- return cached;
+ return cachedHit.Value;
}
var acceptLanguageHeaders = context.Request.GetTypedHeaders().AcceptLanguage;
@@ -111,14 +116,14 @@ private string ResolveFromAcceptLanguage(HttpContext context)
if (supportedLocales.Contains(tag))
{
- cache.Set(cacheKey, tag, AcceptLanguageCacheDuration);
+ await cache.SetAsync(cacheKey, tag, AcceptLanguageCacheOptions);
return tag;
}
var twoLetter = tag.Split('-')[0];
if (supportedLocales.Contains(twoLetter))
{
- cache.Set(cacheKey, twoLetter, AcceptLanguageCacheDuration);
+ await cache.SetAsync(cacheKey, twoLetter, AcceptLanguageCacheOptions);
return twoLetter;
}
}
diff --git a/modules/Localization/tests/SimpleModule.Localization.Tests/Unit/LocaleResolutionMiddlewareTests.cs b/modules/Localization/tests/SimpleModule.Localization.Tests/Unit/LocaleResolutionMiddlewareTests.cs
index f2118b77..5f3eeb5b 100644
--- a/modules/Localization/tests/SimpleModule.Localization.Tests/Unit/LocaleResolutionMiddlewareTests.cs
+++ b/modules/Localization/tests/SimpleModule.Localization.Tests/Unit/LocaleResolutionMiddlewareTests.cs
@@ -5,6 +5,7 @@
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
+using SimpleModule.Core.Caching;
using SimpleModule.Core.Inertia;
using SimpleModule.Core.Settings;
using SimpleModule.Localization.Middleware;
@@ -17,6 +18,7 @@ public sealed class LocaleResolutionMiddlewareTests : IDisposable
{
private readonly TranslationLoader _loader;
private readonly MemoryCache _cache = new(new MemoryCacheOptions());
+ private readonly MemoryCacheStore _cacheStore;
public LocaleResolutionMiddlewareTests()
{
@@ -29,6 +31,7 @@ public LocaleResolutionMiddlewareTests()
"es",
new Dictionary { ["common.save"] = "Guardar" }
);
+ _cacheStore = new MemoryCacheStore(_cache);
}
[Fact]
@@ -41,7 +44,7 @@ public async Task Invoke_AuthenticatedUserWithLanguageSetting_UsesUserLocale()
var middleware = CreateMiddleware(
CaptureLocale(v => capturedLocale = v),
CreateConfiguration(null),
- _cache
+ _cacheStore
);
await middleware.InvokeAsync(context);
@@ -64,7 +67,7 @@ public async Task Invoke_AnonymousWithAcceptLanguageHeader_UsesHeaderLocale()
var middleware = CreateMiddleware(
CaptureLocale(v => capturedLocale = v),
CreateConfiguration(null),
- _cache
+ _cacheStore
);
await middleware.InvokeAsync(context);
@@ -82,7 +85,7 @@ public async Task Invoke_NoHeaderNoSetting_UsesConfigDefault()
var middleware = CreateMiddleware(
CaptureLocale(v => capturedLocale = v),
CreateConfiguration("es"),
- _cache
+ _cacheStore
);
await middleware.InvokeAsync(context);
@@ -100,7 +103,7 @@ public async Task Invoke_NoHeaderNoSettingNoConfig_FallsBackToEn()
var middleware = CreateMiddleware(
CaptureLocale(v => capturedLocale = v),
CreateConfiguration(null),
- _cache
+ _cacheStore
);
await middleware.InvokeAsync(context);
@@ -114,11 +117,12 @@ public async Task Invoke_CachesExplicitUserSetting()
var callCount = 0;
var settings = new FakeSettingsContracts("es", onGet: () => callCount++);
using var localCache = new MemoryCache(new MemoryCacheOptions());
+ using var localCacheStore = new MemoryCacheStore(localCache);
var middleware = CreateMiddleware(
_ => Task.CompletedTask,
CreateConfiguration(null),
- localCache
+ localCacheStore
);
var context1 = CreateHttpContext(settings, userId: "user-1");
@@ -140,11 +144,12 @@ public async Task Invoke_DoesNotCacheFallbackPerUser()
var callCount = 0;
var settings = new FakeSettingsContracts(null, onGet: () => callCount++);
using var localCache = new MemoryCache(new MemoryCacheOptions());
+ using var localCacheStore = new MemoryCacheStore(localCache);
var middleware = CreateMiddleware(
_ => Task.CompletedTask,
CreateConfiguration(null),
- localCache
+ localCacheStore
);
var context1 = CreateHttpContext(settings, userId: "user-2");
@@ -160,7 +165,7 @@ public async Task Invoke_DoesNotCacheFallbackPerUser()
private LocaleResolutionMiddleware CreateMiddleware(
RequestDelegate next,
IConfiguration config,
- IMemoryCache cache
+ ICacheStore cache
)
{
return new LocaleResolutionMiddleware(next, config, _loader, cache);
@@ -253,5 +258,9 @@ public Task> GetSettingsAsync(SettingsFilter? filter = null
}
}
- public void Dispose() => _cache.Dispose();
+ public void Dispose()
+ {
+ _cacheStore.Dispose();
+ _cache.Dispose();
+ }
}
diff --git a/modules/Marketplace/src/SimpleModule.Marketplace/InstalledPackageDetector.cs b/modules/Marketplace/src/SimpleModule.Marketplace/InstalledPackageDetector.cs
index ce2a5abf..2d191883 100644
--- a/modules/Marketplace/src/SimpleModule.Marketplace/InstalledPackageDetector.cs
+++ b/modules/Marketplace/src/SimpleModule.Marketplace/InstalledPackageDetector.cs
@@ -1,31 +1,30 @@
using System.Xml;
using System.Xml.Linq;
using Microsoft.AspNetCore.Hosting;
-using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
+using SimpleModule.Core.Caching;
namespace SimpleModule.Marketplace;
public partial class InstalledPackageDetector(
IWebHostEnvironment environment,
- IMemoryCache cache,
+ ICacheStore cache,
ILogger logger
)
{
private const string CacheKey = "Marketplace:InstalledPackages";
+ private static readonly CacheEntryOptions CacheOptions = CacheEntryOptions.Expires(
+ TimeSpan.FromMinutes(1)
+ );
- public Task> GetInstalledPackageIdsAsync()
+ public async Task> GetInstalledPackageIdsAsync()
{
- return Task.FromResult(
- cache.GetOrCreate(
- CacheKey,
- entry =>
- {
- entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(1);
- return ReadInstalledPackages();
- }
- ) ?? []
+ var result = await cache.GetOrCreateAsync>(
+ CacheKey,
+ _ => new ValueTask?>(ReadInstalledPackages()),
+ CacheOptions
);
+ return result ?? [];
}
private HashSet ReadInstalledPackages()
diff --git a/modules/Marketplace/src/SimpleModule.Marketplace/MarketplaceModule.cs b/modules/Marketplace/src/SimpleModule.Marketplace/MarketplaceModule.cs
index e87b9acf..dbceeee0 100644
--- a/modules/Marketplace/src/SimpleModule.Marketplace/MarketplaceModule.cs
+++ b/modules/Marketplace/src/SimpleModule.Marketplace/MarketplaceModule.cs
@@ -26,7 +26,6 @@ public void ConfigureServices(IServiceCollection services, IConfiguration config
}
);
- services.AddMemoryCache();
services.AddSingleton();
}
diff --git a/modules/Marketplace/src/SimpleModule.Marketplace/NuGetMarketplaceService.cs b/modules/Marketplace/src/SimpleModule.Marketplace/NuGetMarketplaceService.cs
index dbda4f35..bcccfe60 100644
--- a/modules/Marketplace/src/SimpleModule.Marketplace/NuGetMarketplaceService.cs
+++ b/modules/Marketplace/src/SimpleModule.Marketplace/NuGetMarketplaceService.cs
@@ -1,8 +1,8 @@
using System.Diagnostics.CodeAnalysis;
using System.Net.Http.Json;
using System.Text.Json.Serialization;
-using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Options;
+using SimpleModule.Core.Caching;
using SimpleModule.Marketplace.Contracts;
namespace SimpleModule.Marketplace;
@@ -11,7 +11,7 @@ public class NuGetMarketplaceService(
IHttpClientFactory httpClientFactory,
IOptions options,
InstalledPackageDetector installedPackageDetector,
- IMemoryCache cache
+ ICacheStore cache
) : IMarketplaceContracts
{
public async Task SearchPackagesAsync(MarketplaceSearchRequest request)
@@ -20,13 +20,10 @@ public async Task SearchPackagesAsync(MarketplaceSearch
var cached = await cache.GetOrCreateAsync(
cacheKey,
- async entry =>
- {
- entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(
- options.Value.SearchCacheDurationMinutes
- );
- return await FetchAllPackagesAsync(request.Query);
- }
+ async _ => await FetchAllPackagesAsync(request.Query),
+ CacheEntryOptions.Expires(
+ TimeSpan.FromMinutes(options.Value.SearchCacheDurationMinutes)
+ )
);
var result = cached ?? new MarketplaceSearchResult();
@@ -60,13 +57,10 @@ .. packages.OrderByDescending(p => p.TotalDownloads),
return await cache.GetOrCreateAsync(
cacheKey,
- async entry =>
- {
- entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(
- options.Value.DetailCacheDurationMinutes
- );
- return await FetchPackageDetailsAsync(packageId);
- }
+ async _ => await FetchPackageDetailsAsync(packageId),
+ CacheEntryOptions.Expires(
+ TimeSpan.FromMinutes(options.Value.DetailCacheDurationMinutes)
+ )
);
}
diff --git a/modules/Permissions/src/SimpleModule.Permissions/PermissionClaimsTransformation.cs b/modules/Permissions/src/SimpleModule.Permissions/PermissionClaimsTransformation.cs
index e7504bb1..511bac71 100644
--- a/modules/Permissions/src/SimpleModule.Permissions/PermissionClaimsTransformation.cs
+++ b/modules/Permissions/src/SimpleModule.Permissions/PermissionClaimsTransformation.cs
@@ -1,6 +1,6 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Authentication;
-using Microsoft.Extensions.Caching.Memory;
+using SimpleModule.Core.Caching;
using SimpleModule.Core.Extensions;
using SimpleModule.Permissions.Contracts;
using SimpleModule.Users.Contracts;
@@ -10,10 +10,12 @@ namespace SimpleModule.Permissions;
public sealed class PermissionClaimsTransformation(
IPermissionContracts permissionContracts,
IUserContracts userContracts,
- IMemoryCache cache
+ ICacheStore cache
) : IClaimsTransformation
{
- private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(5);
+ private static readonly CacheEntryOptions CacheOptions = CacheEntryOptions.Expires(
+ TimeSpan.FromMinutes(5)
+ );
public async Task TransformAsync(ClaimsPrincipal principal)
{
@@ -33,23 +35,26 @@ public async Task TransformAsync(ClaimsPrincipal principal)
var rolesKey = string.Join(',', roles.Order());
var cacheKey = $"permissions:{userId}:{rolesKey}";
- if (!cache.TryGetValue(cacheKey, out IReadOnlySet? permissions))
- {
- var roleIdMap =
- roles.Count > 0
- ? await userContracts.GetRoleIdsByNamesAsync(roles)
- : new Dictionary();
-
- permissions = await permissionContracts.GetAllPermissionsForUserAsync(
- UserId.From(userId),
- roleIdMap.Values.Select(id => RoleId.From(id))
- );
+ var permissions =
+ await cache.GetOrCreateAsync>(
+ cacheKey,
+ async _ =>
+ {
+ var roleIdMap =
+ roles.Count > 0
+ ? await userContracts.GetRoleIdsByNamesAsync(roles)
+ : new Dictionary();
- cache.Set(cacheKey, permissions, CacheDuration);
- }
+ return await permissionContracts.GetAllPermissionsForUserAsync(
+ UserId.From(userId),
+ roleIdMap.Values.Select(id => RoleId.From(id))
+ );
+ },
+ CacheOptions
+ ) ?? new HashSet();
var identity = new ClaimsIdentity();
- foreach (var permission in permissions!)
+ foreach (var permission in permissions)
{
identity.AddClaim(new Claim("permission", permission));
}
diff --git a/modules/Settings/src/SimpleModule.Settings/Services/PublicMenuService.cs b/modules/Settings/src/SimpleModule.Settings/Services/PublicMenuService.cs
index 25cf35d4..6a0a2531 100644
--- a/modules/Settings/src/SimpleModule.Settings/Services/PublicMenuService.cs
+++ b/modules/Settings/src/SimpleModule.Settings/Services/PublicMenuService.cs
@@ -1,6 +1,6 @@
using Microsoft.EntityFrameworkCore;
-using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Options;
+using SimpleModule.Core.Caching;
using SimpleModule.Core.Menu;
using SimpleModule.Settings.Contracts;
using SimpleModule.Settings.Entities;
@@ -9,7 +9,7 @@ namespace SimpleModule.Settings.Services;
public sealed class PublicMenuService(
SettingsDbContext db,
- IMemoryCache cache,
+ ICacheStore cache,
IOptions moduleOptions
) : IPublicMenuProvider
{
@@ -18,20 +18,19 @@ IOptions moduleOptions
public async Task> GetMenuTreeAsync()
{
- if (
- cache.TryGetValue(MenuTreeCacheKey, out IReadOnlyList? cached)
- && cached is not null
- )
- return cached;
-
- var entities = await db
- .PublicMenuItems.Where(e => e.IsVisible)
- .OrderBy(e => e.SortOrder)
- .ToListAsync();
-
- var tree = BuildPublicTree(entities, parentId: null);
- cache.Set(MenuTreeCacheKey, tree, moduleOptions.Value.CacheDuration);
- return tree;
+ var result = await cache.GetOrCreateAsync>(
+ MenuTreeCacheKey,
+ async ct =>
+ {
+ var entities = await db
+ .PublicMenuItems.Where(e => e.IsVisible)
+ .OrderBy(e => e.SortOrder)
+ .ToListAsync(ct);
+ return BuildPublicTree(entities, parentId: null);
+ },
+ CacheEntryOptions.Expires(moduleOptions.Value.CacheDuration)
+ );
+ return result ?? [];
}
[System.Diagnostics.CodeAnalysis.SuppressMessage(
@@ -41,16 +40,17 @@ public async Task> GetMenuTreeAsync()
)]
public async Task GetHomePageUrlAsync()
{
- if (cache.TryGetValue(HomePageCacheKey, out string? cached))
- return cached;
-
- var entity = await db
- .PublicMenuItems.Where(e => e.IsVisible && e.IsHomePage)
- .FirstOrDefaultAsync();
-
- var url = entity is not null ? (entity.Url ?? entity.PageRoute) : null;
- cache.Set(HomePageCacheKey, url, moduleOptions.Value.CacheDuration);
- return url;
+ return await cache.GetOrCreateAsync(
+ HomePageCacheKey,
+ async ct =>
+ {
+ var entity = await db
+ .PublicMenuItems.Where(e => e.IsVisible && e.IsHomePage)
+ .FirstOrDefaultAsync(ct);
+ return entity is not null ? (entity.Url ?? entity.PageRoute) : null;
+ },
+ CacheEntryOptions.Expires(moduleOptions.Value.CacheDuration)
+ );
}
public async Task> GetAllAsync()
@@ -98,7 +98,7 @@ await db
db.PublicMenuItems.Add(entity);
await db.SaveChangesAsync();
- InvalidateCache();
+ await InvalidateCache();
return entity;
}
@@ -122,7 +122,7 @@ await db
entity.UpdatedAt = DateTimeOffset.UtcNow;
await db.SaveChangesAsync();
- InvalidateCache();
+ await InvalidateCache();
return entity;
}
@@ -134,7 +134,7 @@ public async Task DeleteAsync(int id)
db.PublicMenuItems.Remove(entity);
await db.SaveChangesAsync();
- InvalidateCache();
+ await InvalidateCache();
return true;
}
@@ -152,7 +152,7 @@ public async Task ReorderAsync(ReorderMenuItemsRequest request)
}
await db.SaveChangesAsync();
- InvalidateCache();
+ await InvalidateCache();
}
public async Task SetHomePageAsync(int id)
@@ -167,14 +167,14 @@ public async Task SetHomePageAsync(int id)
await db.SaveChangesAsync();
}
- InvalidateCache();
+ await InvalidateCache();
}
public async Task ClearHomePageAsync()
{
await ClearAllHomePageFlags();
await db.SaveChangesAsync();
- InvalidateCache();
+ await InvalidateCache();
}
private async Task ClearAllHomePageFlags()
@@ -253,9 +253,9 @@ private static List BuildDtoTree(
.ToList();
}
- private void InvalidateCache()
+ private async ValueTask InvalidateCache()
{
- cache.Remove(MenuTreeCacheKey);
- cache.Remove(HomePageCacheKey);
+ await cache.RemoveAsync(MenuTreeCacheKey);
+ await cache.RemoveAsync(HomePageCacheKey);
}
}
diff --git a/modules/Settings/src/SimpleModule.Settings/SettingsModule.cs b/modules/Settings/src/SimpleModule.Settings/SettingsModule.cs
index e8401c2c..949a19c7 100644
--- a/modules/Settings/src/SimpleModule.Settings/SettingsModule.cs
+++ b/modules/Settings/src/SimpleModule.Settings/SettingsModule.cs
@@ -18,7 +18,6 @@ public class SettingsModule : IModule
{
public void ConfigureServices(IServiceCollection services, IConfiguration configuration)
{
- services.AddMemoryCache();
services.AddModuleDbContext(configuration, SettingsConstants.ModuleName);
services.AddScoped();
services.AddScoped(sp => sp.GetRequiredService());
diff --git a/modules/Settings/src/SimpleModule.Settings/SettingsService.cs b/modules/Settings/src/SimpleModule.Settings/SettingsService.cs
index c6f64d9c..3b656fdb 100644
--- a/modules/Settings/src/SimpleModule.Settings/SettingsService.cs
+++ b/modules/Settings/src/SimpleModule.Settings/SettingsService.cs
@@ -1,8 +1,8 @@
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
-using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
+using SimpleModule.Core.Caching;
using SimpleModule.Core.Settings;
using SimpleModule.Settings.Contracts;
using SimpleModule.Settings.Entities;
@@ -12,7 +12,7 @@ namespace SimpleModule.Settings;
public sealed partial class SettingsService(
SettingsDbContext db,
ISettingsDefinitionRegistry definitions,
- IMemoryCache cache,
+ ICacheStore cache,
IOptions moduleOptions,
ILogger logger
) : ISettingsContracts
@@ -25,8 +25,9 @@ ILogger logger
{
var cacheKey = BuildCacheKey(key, scope, userId);
- if (cache.TryGetValue(cacheKey, out string? cached))
- return cached;
+ var hit = await cache.TryGetAsync(cacheKey);
+ if (hit.Hit)
+ return hit.Value;
var entity = await db
.Settings.AsNoTracking()
@@ -36,7 +37,11 @@ ILogger logger
&& (scope == SettingScope.User ? s.UserId == userId : s.UserId == null)
);
- cache.Set(cacheKey, entity?.Value, moduleOptions.Value.CacheDuration);
+ await cache.SetAsync(
+ cacheKey,
+ entity?.Value,
+ CacheEntryOptions.Expires(moduleOptions.Value.CacheDuration)
+ );
return entity?.Value;
}
@@ -104,7 +109,7 @@ public async Task SetSettingAsync(
}
await db.SaveChangesAsync();
- cache.Remove(BuildCacheKey(key, scope, userId));
+ await cache.RemoveAsync(BuildCacheKey(key, scope, userId));
LogSettingUpdated(key, scope);
}
@@ -120,7 +125,7 @@ public async Task DeleteSettingAsync(string key, SettingScope scope, string? use
{
db.Settings.Remove(entity);
await db.SaveChangesAsync();
- cache.Remove(BuildCacheKey(key, scope, userId));
+ await cache.RemoveAsync(BuildCacheKey(key, scope, userId));
LogSettingDeleted(key, scope);
}
}
@@ -174,5 +179,5 @@ public async Task> GetSettingsAsync(SettingsFilter? filter
private partial void LogDeserializationError(string key, string type, string error);
private static string BuildCacheKey(string key, SettingScope scope, string? userId) =>
- userId is not null ? $"setting:{scope}:{userId}:{key}" : $"setting:{scope}:{key}";
+ CacheKey.Compose("setting", scope.ToString(), userId, key);
}
diff --git a/modules/Settings/tests/SimpleModule.Settings.Tests/Unit/PublicMenuServiceTests.cs b/modules/Settings/tests/SimpleModule.Settings.Tests/Unit/PublicMenuServiceTests.cs
index 38386bf7..0a165016 100644
--- a/modules/Settings/tests/SimpleModule.Settings.Tests/Unit/PublicMenuServiceTests.cs
+++ b/modules/Settings/tests/SimpleModule.Settings.Tests/Unit/PublicMenuServiceTests.cs
@@ -2,6 +2,7 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Options;
+using SimpleModule.Core.Caching;
using SimpleModule.Database;
using SimpleModule.Settings;
using SimpleModule.Settings.Contracts;
@@ -15,6 +16,7 @@ public sealed class PublicMenuServiceTests : IDisposable
private readonly SettingsDbContext _db;
private readonly PublicMenuService _service;
private readonly MemoryCache _cache;
+ private readonly MemoryCacheStore _cacheStore;
public PublicMenuServiceTests()
{
@@ -28,7 +30,12 @@ public PublicMenuServiceTests()
_db.Database.EnsureCreated();
_cache = new MemoryCache(new MemoryCacheOptions());
- _service = new PublicMenuService(_db, _cache, Options.Create(new SettingsModuleOptions()));
+ _cacheStore = new MemoryCacheStore(_cache);
+ _service = new PublicMenuService(
+ _db,
+ _cacheStore,
+ Options.Create(new SettingsModuleOptions())
+ );
}
[Fact]
@@ -218,6 +225,7 @@ public async Task SetHomePageAsync_ClearsPreviousHomePage()
public void Dispose()
{
+ _cacheStore.Dispose();
_cache.Dispose();
_db.Dispose();
GC.SuppressFinalize(this);
diff --git a/modules/Settings/tests/SimpleModule.Settings.Tests/Unit/SettingsServiceTests.cs b/modules/Settings/tests/SimpleModule.Settings.Tests/Unit/SettingsServiceTests.cs
index 00cfede9..24328241 100644
--- a/modules/Settings/tests/SimpleModule.Settings.Tests/Unit/SettingsServiceTests.cs
+++ b/modules/Settings/tests/SimpleModule.Settings.Tests/Unit/SettingsServiceTests.cs
@@ -3,6 +3,7 @@
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
+using SimpleModule.Core.Caching;
using SimpleModule.Core.Settings;
using SimpleModule.Database;
using SimpleModule.Settings;
@@ -13,6 +14,7 @@ public sealed class SettingsServiceTests : IDisposable
{
private readonly SettingsDbContext _db;
private readonly MemoryCache _cache;
+ private readonly MemoryCacheStore _cacheStore;
private readonly SettingsService _service;
public SettingsServiceTests()
@@ -38,10 +40,11 @@ public SettingsServiceTests()
]);
_cache = new MemoryCache(new MemoryCacheOptions());
+ _cacheStore = new MemoryCacheStore(_cache);
_service = new SettingsService(
_db,
registry,
- _cache,
+ _cacheStore,
Options.Create(new SettingsModuleOptions()),
NullLogger.Instance
);
@@ -139,6 +142,7 @@ public async Task GetSettingAsync_Bool_NoDbValue_ReturnsFalse()
public void Dispose()
{
+ _cacheStore.Dispose();
_cache.Dispose();
_db.Dispose();
GC.SuppressFinalize(this);
diff --git a/modules/Tenants/src/SimpleModule.Tenants/Resolvers/HostNameTenantResolver.cs b/modules/Tenants/src/SimpleModule.Tenants/Resolvers/HostNameTenantResolver.cs
index 861d0f92..ab82e928 100644
--- a/modules/Tenants/src/SimpleModule.Tenants/Resolvers/HostNameTenantResolver.cs
+++ b/modules/Tenants/src/SimpleModule.Tenants/Resolvers/HostNameTenantResolver.cs
@@ -1,15 +1,14 @@
using Microsoft.AspNetCore.Http;
using Microsoft.EntityFrameworkCore;
-using Microsoft.Extensions.Caching.Memory;
+using SimpleModule.Core.Caching;
namespace SimpleModule.Tenants.Resolvers;
-public sealed class HostNameTenantResolver(TenantsDbContext db, IMemoryCache cache)
+public sealed class HostNameTenantResolver(TenantsDbContext db, ICacheStore cache)
{
- private static readonly MemoryCacheEntryOptions CacheOptions = new()
- {
- AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5),
- };
+ private static readonly CacheEntryOptions CacheOptions = CacheEntryOptions.Expires(
+ TimeSpan.FromMinutes(5)
+ );
public async Task ResolveAsync(HttpContext context)
{
@@ -22,18 +21,17 @@ public sealed class HostNameTenantResolver(TenantsDbContext db, IMemoryCache cac
var cacheKey = $"tenant:host:{host}";
return await cache.GetOrCreateAsync(
cacheKey,
- async entry =>
+ async ct =>
{
- entry.SetOptions(CacheOptions);
-
var tenantHost = await db
.TenantHosts.AsNoTracking()
.Where(h => h.HostName == host && h.IsActive)
.Select(h => (int?)h.TenantId.Value)
- .FirstOrDefaultAsync();
+ .FirstOrDefaultAsync(ct);
return tenantHost?.ToString(System.Globalization.CultureInfo.InvariantCulture);
- }
+ },
+ CacheOptions
);
}
}
diff --git a/modules/Tenants/src/SimpleModule.Tenants/TenantsModule.cs b/modules/Tenants/src/SimpleModule.Tenants/TenantsModule.cs
index 18076a10..ab40eee6 100644
--- a/modules/Tenants/src/SimpleModule.Tenants/TenantsModule.cs
+++ b/modules/Tenants/src/SimpleModule.Tenants/TenantsModule.cs
@@ -17,7 +17,6 @@ public class TenantsModule : IModule
{
public void ConfigureServices(IServiceCollection services, IConfiguration configuration)
{
- services.AddMemoryCache();
services.AddModuleDbContext(configuration, TenantsConstants.ModuleName);
services.AddScoped();
services.AddScoped();
diff --git a/tests/SimpleModule.Core.Tests/Caching/MemoryCacheStoreTests.cs b/tests/SimpleModule.Core.Tests/Caching/MemoryCacheStoreTests.cs
new file mode 100644
index 00000000..f88b7440
--- /dev/null
+++ b/tests/SimpleModule.Core.Tests/Caching/MemoryCacheStoreTests.cs
@@ -0,0 +1,300 @@
+using System.Collections.Concurrent;
+using System.Reflection;
+using FluentAssertions;
+using Microsoft.Extensions.Caching.Memory;
+using SimpleModule.Core.Caching;
+
+namespace SimpleModule.Core.Tests.Caching;
+
+public sealed class MemoryCacheStoreTests : IDisposable
+{
+ private readonly MemoryCache _memoryCache = new(new MemoryCacheOptions());
+ private readonly MemoryCacheStore _store;
+
+ public MemoryCacheStoreTests()
+ {
+ _store = new MemoryCacheStore(_memoryCache);
+ }
+
+ [Fact]
+ public async Task TryGetAsync_ReturnsMiss_WhenKeyAbsent()
+ {
+ var result = await _store.TryGetAsync("missing");
+
+ result.Hit.Should().BeFalse();
+ result.Value.Should().BeNull();
+ }
+
+ [Fact]
+ public async Task SetAsync_ThenTryGetAsync_RoundTripsValue()
+ {
+ await _store.SetAsync("k1", "value");
+
+ var result = await _store.TryGetAsync("k1");
+
+ result.Hit.Should().BeTrue();
+ result.Value.Should().Be("value");
+ }
+
+ [Fact]
+ public async Task SetAsync_AllowsCachingNullForNegativeCaching()
+ {
+ await _store.SetAsync("k1", null);
+
+ var result = await _store.TryGetAsync("k1");
+
+ result.Hit.Should().BeTrue("a cached null should be a hit, not a miss");
+ result.Value.Should().BeNull();
+ }
+
+ [Fact]
+ public async Task SetAsync_OverwritesExistingValue()
+ {
+ await _store.SetAsync("k1", "first");
+ await _store.SetAsync("k1", "second");
+
+ var result = await _store.TryGetAsync("k1");
+
+ result.Value.Should().Be("second");
+ }
+
+ [Fact]
+ public async Task RemoveAsync_DeletesEntry()
+ {
+ await _store.SetAsync("k1", "value");
+
+ await _store.RemoveAsync("k1");
+
+ var result = await _store.TryGetAsync("k1");
+ result.Hit.Should().BeFalse();
+ }
+
+ [Fact]
+ public async Task RemoveAsync_IsNoOp_WhenKeyAbsent()
+ {
+ var act = async () => await _store.RemoveAsync("ghost");
+
+ await act.Should().NotThrowAsync();
+ }
+
+ [Fact]
+ public async Task GetOrCreateAsync_InvokesFactory_OnMiss()
+ {
+ var calls = 0;
+
+ var value = await _store.GetOrCreateAsync(
+ "k1",
+ _ =>
+ {
+ calls++;
+ return new ValueTask("created");
+ }
+ );
+
+ value.Should().Be("created");
+ calls.Should().Be(1);
+ }
+
+ [Fact]
+ public async Task GetOrCreateAsync_SkipsFactory_OnHit()
+ {
+ await _store.SetAsync("k1", "existing");
+ var calls = 0;
+
+ var value = await _store.GetOrCreateAsync(
+ "k1",
+ _ =>
+ {
+ calls++;
+ return new ValueTask("created");
+ }
+ );
+
+ value.Should().Be("existing");
+ calls.Should().Be(0);
+ }
+
+ [Fact]
+ public async Task GetOrCreateAsync_PreventsStampede_UnderConcurrentCallers()
+ {
+ var factoryCalls = 0;
+ var gate = new TaskCompletionSource();
+
+ async ValueTask Factory(CancellationToken _)
+ {
+ Interlocked.Increment(ref factoryCalls);
+ await gate.Task;
+ return "value";
+ }
+
+ var t1 = _store.GetOrCreateAsync("stampede", Factory).AsTask();
+ var t2 = _store.GetOrCreateAsync("stampede", Factory).AsTask();
+ var t3 = _store.GetOrCreateAsync("stampede", Factory).AsTask();
+
+ // Let the first factory release.
+ gate.SetResult();
+ var results = await Task.WhenAll(t1, t2, t3);
+
+ factoryCalls
+ .Should()
+ .Be(1, "concurrent GetOrCreateAsync calls for the same key must coalesce");
+ results.Should().AllBeEquivalentTo("value");
+ }
+
+ [Fact]
+ public async Task GetOrCreateAsync_RespectsExpirationOptions()
+ {
+ await _store.GetOrCreateAsync(
+ "k1",
+ _ => new ValueTask("v"),
+ CacheEntryOptions.Expires(TimeSpan.FromMilliseconds(50))
+ );
+
+ await Task.Delay(150);
+
+ var result = await _store.TryGetAsync("k1");
+ result.Hit.Should().BeFalse();
+ }
+
+ [Fact]
+ public async Task RemoveByPrefixAsync_RemovesAllMatchingKeys()
+ {
+ await _store.SetAsync("user:1:profile", "p1");
+ await _store.SetAsync("user:1:settings", "s1");
+ await _store.SetAsync("user:2:profile", "p2");
+ await _store.SetAsync("system:bootstrap", "x");
+
+ await _store.RemoveByPrefixAsync("user:1:");
+
+ (await _store.TryGetAsync("user:1:profile")).Hit.Should().BeFalse();
+ (await _store.TryGetAsync("user:1:settings")).Hit.Should().BeFalse();
+ (await _store.TryGetAsync("user:2:profile")).Hit.Should().BeTrue();
+ (await _store.TryGetAsync("system:bootstrap")).Hit.Should().BeTrue();
+ }
+
+ [Fact]
+ public async Task RemoveByPrefixAsync_RemovesEverything_WithEmptyKey()
+ {
+ await _store.SetAsync("a", "1");
+ await _store.SetAsync("b", "2");
+
+ await _store.RemoveByPrefixAsync("a");
+
+ (await _store.TryGetAsync("a")).Hit.Should().BeFalse();
+ (await _store.TryGetAsync("b")).Hit.Should().BeTrue();
+ }
+
+ [Fact]
+ public async Task TryGetAsync_ThrowsOnNullOrEmptyKey()
+ {
+ var act1 = async () => await _store.TryGetAsync(null!);
+ var act2 = async () => await _store.TryGetAsync(string.Empty);
+
+ await act1.Should().ThrowAsync();
+ await act2.Should().ThrowAsync();
+ }
+
+ [Fact]
+ public async Task WithPrefix_ScopesAllOperations()
+ {
+ var scoped = _store.WithPrefix("tenant-a");
+
+ await scoped.SetAsync("user", "alice");
+
+ // Scoped store sees the value.
+ var hit = await scoped.TryGetAsync("user");
+ hit.Hit.Should().BeTrue();
+ hit.Value.Should().Be("alice");
+
+ // Underlying store sees the prefixed key.
+ var underlying = await _store.TryGetAsync("tenant-a:user");
+ underlying.Hit.Should().BeTrue();
+ underlying.Value.Should().Be("alice");
+
+ // The unscoped key does not exist.
+ (await _store.TryGetAsync("user"))
+ .Hit.Should()
+ .BeFalse();
+ }
+
+ [Fact]
+ public async Task WithPrefix_RemoveByPrefix_ScopesPrefix()
+ {
+ var scoped = _store.WithPrefix("tenant-a");
+ await scoped.SetAsync("user:1", "alice");
+ await scoped.SetAsync("user:2", "bob");
+ await _store.SetAsync("user:1", "global"); // unscoped, must survive
+
+ await scoped.RemoveByPrefixAsync("user");
+
+ (await scoped.TryGetAsync("user:1")).Hit.Should().BeFalse();
+ (await scoped.TryGetAsync("user:2")).Hit.Should().BeFalse();
+ (await _store.TryGetAsync("user:1")).Hit.Should().BeTrue();
+ }
+
+ [Fact]
+ public async Task GetOrCreateAsync_DoesNotLeakPerKeyLocks()
+ {
+ // Populate many distinct keys. After each uncontended call the per-key
+ // semaphore should be released; the lock dictionary must not grow unbounded.
+ for (var i = 0; i < 50; i++)
+ {
+ await _store.GetOrCreateAsync($"leak:{i}", _ => new ValueTask(i));
+ }
+
+ GetKeyLocks(_store).Should().BeEmpty();
+ }
+
+ [Fact]
+ public async Task RemoveAsync_DuringInFlightFactory_DoesNotCrash()
+ {
+ // RemoveAsync must not dispose a semaphore that a concurrent GetOrCreateAsync
+ // caller is still holding. The in-flight caller's own finally block reclaims
+ // the lock after it releases the gate.
+ var factoryGate = new TaskCompletionSource();
+ var held = _store
+ .GetOrCreateAsync(
+ "contended",
+ async _ =>
+ {
+ await factoryGate.Task;
+ return "value";
+ }
+ )
+ .AsTask();
+
+ await Task.Yield();
+ await _store.RemoveAsync("contended");
+
+ factoryGate.SetResult();
+ var result = await held;
+
+ result.Should().Be("value");
+ GetKeyLocks(_store).Should().NotContainKey("contended");
+ }
+
+ private static ConcurrentDictionary GetKeyLocks(MemoryCacheStore store)
+ {
+ var field = typeof(MemoryCacheStore).GetField(
+ "_keyLocks",
+ BindingFlags.NonPublic | BindingFlags.Instance
+ )!;
+ return (ConcurrentDictionary)field.GetValue(store)!;
+ }
+
+ [Fact]
+ public void CacheKey_Compose_JoinsPartsAndSkipsEmpty()
+ {
+ CacheKey.Compose("a", "b", "c").Should().Be("a:b:c");
+ CacheKey.Compose("a", null, "c").Should().Be("a:c");
+ CacheKey.Compose("a", string.Empty, "c").Should().Be("a:c");
+ CacheKey.Compose("only").Should().Be("only");
+ }
+
+ public void Dispose()
+ {
+ _store.Dispose();
+ _memoryCache.Dispose();
+ GC.SuppressFinalize(this);
+ }
+}