diff --git a/src/CleanArchitecture.Extensions.Caching/Abstractions/ICacheKeyProvider.cs b/src/CleanArchitecture.Extensions.Caching/Abstractions/ICacheKeyProvider.cs
new file mode 100644
index 0000000..5384912
--- /dev/null
+++ b/src/CleanArchitecture.Extensions.Caching/Abstractions/ICacheKeyProvider.cs
@@ -0,0 +1,16 @@
+using CleanArchitecture.Extensions.Caching.Keys;
+
+namespace CleanArchitecture.Extensions.Caching.Abstractions;
+
+///
+/// Provides a custom cache hash for requests that cannot be deterministically serialized.
+///
+public interface ICacheKeyProvider
+{
+ ///
+ /// Returns a deterministic hash used to build cache keys.
+ ///
+ /// Cache key factory to help create component hashes.
+ /// A canonical hash string.
+ string GetCacheHash(ICacheKeyFactory keyFactory);
+}
diff --git a/src/CleanArchitecture.Extensions.Caching/Behaviors/QueryCachingBehavior.cs b/src/CleanArchitecture.Extensions.Caching/Behaviors/QueryCachingBehavior.cs
index 0f57b0d..c705d7f 100644
--- a/src/CleanArchitecture.Extensions.Caching/Behaviors/QueryCachingBehavior.cs
+++ b/src/CleanArchitecture.Extensions.Caching/Behaviors/QueryCachingBehavior.cs
@@ -51,7 +51,17 @@ public async Task Handle(TRequest request, RequestHandlerDelegate(key, cancellationToken).ConfigureAwait(false);
if (cached is not null)
{
@@ -111,7 +121,9 @@ public async Task Handle(TRequest request, RequestHandlerDelegate();
services.TryAddSingleton(sp =>
- ActivatorUtilities.CreateInstance(sp, sp.GetServices()));
+ {
+ var options = sp.GetRequiredService>().Value;
+ var serializers = sp.GetServices();
+ var distributedCache = sp.GetService();
+
+ var useDistributed = options.Backend switch
+ {
+ CacheBackend.Distributed => true,
+ CacheBackend.Memory => false,
+ _ => distributedCache is not null && distributedCache is not MemoryDistributedCache
+ };
+
+ if (useDistributed)
+ {
+ if (distributedCache is null)
+ {
+ throw new InvalidOperationException("CachingOptions.Backend is set to Distributed but no IDistributedCache is registered.");
+ }
+
+ return ActivatorUtilities.CreateInstance(sp, serializers);
+ }
+
+ return ActivatorUtilities.CreateInstance(sp, serializers);
+ });
services.TryAddSingleton(sp =>
ActivatorUtilities.CreateInstance(sp, sp.GetServices()));
diff --git a/src/CleanArchitecture.Extensions.Caching/Options/CacheBackend.cs b/src/CleanArchitecture.Extensions.Caching/Options/CacheBackend.cs
new file mode 100644
index 0000000..d240eca
--- /dev/null
+++ b/src/CleanArchitecture.Extensions.Caching/Options/CacheBackend.cs
@@ -0,0 +1,22 @@
+namespace CleanArchitecture.Extensions.Caching.Options;
+
+///
+/// Defines the cache backend selection strategy.
+///
+public enum CacheBackend
+{
+ ///
+ /// Use a distributed cache when a non-memory implementation is available; otherwise fallback to memory.
+ ///
+ Auto = 0,
+
+ ///
+ /// Always use the in-memory cache.
+ ///
+ Memory = 1,
+
+ ///
+ /// Always use the distributed cache.
+ ///
+ Distributed = 2
+}
diff --git a/src/CleanArchitecture.Extensions.Caching/Options/CachingOptions.cs b/src/CleanArchitecture.Extensions.Caching/Options/CachingOptions.cs
index 4e061e7..e47e80a 100644
--- a/src/CleanArchitecture.Extensions.Caching/Options/CachingOptions.cs
+++ b/src/CleanArchitecture.Extensions.Caching/Options/CachingOptions.cs
@@ -10,6 +10,11 @@ public sealed class CachingOptions
///
public bool Enabled { get; set; } = true;
+ ///
+ /// Gets or sets the cache backend selection strategy.
+ ///
+ public CacheBackend Backend { get; set; } = CacheBackend.Auto;
+
///
/// Gets or sets the default namespace applied to cache keys to avoid collisions across applications.
///
diff --git a/src/CleanArchitecture.Extensions.Caching/README.md b/src/CleanArchitecture.Extensions.Caching/README.md
index 0545970..064cda1 100644
--- a/src/CleanArchitecture.Extensions.Caching/README.md
+++ b/src/CleanArchitecture.Extensions.Caching/README.md
@@ -29,6 +29,7 @@ public static void AddInfrastructureServices(this IHostApplicationBuilder builde
{
options.DefaultNamespace = "MyApp";
options.MaxEntrySizeBytes = 256 * 1024;
+ // Set Backend = CacheBackend.Distributed to force shared cache when IDistributedCache is configured.
}, queryOptions =>
{
queryOptions.DefaultTtl = TimeSpan.FromMinutes(5);
@@ -72,6 +73,13 @@ public record GetTodosQuery : IRequest;
// or
public record GetUserQuery(int Id) : IRequest, ICacheableQuery;
+
+// If your query cannot be serialized for hashing (e.g., contains delegates or HttpContext),
+// implement ICacheKeyProvider to supply a deterministic hash:
+public record GetReportQuery(Func Factory) : IRequest, ICacheableQuery, ICacheKeyProvider
+{
+ public string GetCacheHash(ICacheKeyFactory keyFactory) => keyFactory.CreateHash(new { Version = 1 });
+}
```
## Step 5 - What to expect
diff --git a/tests/CleanArchitecture.Extensions.Caching.Tests/CachingOptionsTests.cs b/tests/CleanArchitecture.Extensions.Caching.Tests/CachingOptionsTests.cs
index b76ae1f..b57e1de 100644
--- a/tests/CleanArchitecture.Extensions.Caching.Tests/CachingOptionsTests.cs
+++ b/tests/CleanArchitecture.Extensions.Caching.Tests/CachingOptionsTests.cs
@@ -10,6 +10,7 @@ public void Default_options_set_expected_defaults()
var options = CachingOptions.Default;
Assert.True(options.Enabled);
+ Assert.Equal(CacheBackend.Auto, options.Backend);
Assert.Equal("CleanArchitectureExtensions", options.DefaultNamespace);
Assert.NotNull(options.DefaultEntryOptions);
Assert.Equal(CachePriority.Normal, options.DefaultEntryOptions.Priority);
diff --git a/tests/CleanArchitecture.Extensions.Caching.Tests/DependencyInjectionExtensionsTests.cs b/tests/CleanArchitecture.Extensions.Caching.Tests/DependencyInjectionExtensionsTests.cs
new file mode 100644
index 0000000..38b55cf
--- /dev/null
+++ b/tests/CleanArchitecture.Extensions.Caching.Tests/DependencyInjectionExtensionsTests.cs
@@ -0,0 +1,71 @@
+using System.Linq;
+using CleanArchitecture.Extensions.Caching;
+using CleanArchitecture.Extensions.Caching.Abstractions;
+using CleanArchitecture.Extensions.Caching.Adapters;
+using CleanArchitecture.Extensions.Caching.Options;
+using Microsoft.Extensions.Caching.Distributed;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace CleanArchitecture.Extensions.Caching.Tests;
+
+public class DependencyInjectionExtensionsTests
+{
+ [Fact]
+ public void Uses_memory_cache_adapter_by_default()
+ {
+ var services = new ServiceCollection();
+ services.AddLogging();
+
+ services.AddCleanArchitectureCaching();
+
+ using var provider = services.BuildServiceProvider();
+
+ var cache = provider.GetRequiredService();
+
+ Assert.IsType(cache);
+ }
+
+ [Fact]
+ public void Uses_distributed_cache_when_non_memory_distributed_is_registered()
+ {
+ var services = new ServiceCollection();
+ services.AddLogging();
+
+ services.AddCleanArchitectureCaching();
+ services.AddSingleton();
+
+ using var provider = services.BuildServiceProvider();
+
+ var cache = provider.GetRequiredService();
+
+ Assert.IsType(cache);
+ }
+
+ [Fact]
+ public void Throws_when_distributed_backend_selected_without_distributed_cache()
+ {
+ var services = new ServiceCollection();
+ services.AddLogging();
+
+ services.AddCleanArchitectureCaching(options => options.Backend = CacheBackend.Distributed);
+ // Remove the default distributed cache registration to simulate misconfiguration.
+ var descriptor = services.First(d => d.ServiceType == typeof(IDistributedCache));
+ services.Remove(descriptor);
+
+ using var provider = services.BuildServiceProvider();
+
+ Assert.Throws(() => provider.GetRequiredService());
+ }
+
+ private sealed class FakeDistributedCache : IDistributedCache
+ {
+ public byte[]? Get(string key) => null;
+ public Task GetAsync(string key, CancellationToken token = default) => Task.FromResult(null);
+ public void Refresh(string key) { }
+ public Task RefreshAsync(string key, CancellationToken token = default) => Task.CompletedTask;
+ public void Remove(string key) { }
+ public Task RemoveAsync(string key, CancellationToken token = default) => Task.CompletedTask;
+ public void Set(string key, byte[] value, DistributedCacheEntryOptions options) { }
+ public Task SetAsync(string key, byte[] value, DistributedCacheEntryOptions options, CancellationToken token = default) => Task.CompletedTask;
+ }
+}
diff --git a/tests/CleanArchitecture.Extensions.Caching.Tests/QueryCachingBehaviorTests.cs b/tests/CleanArchitecture.Extensions.Caching.Tests/QueryCachingBehaviorTests.cs
index 34abdd8..ee8babe 100644
--- a/tests/CleanArchitecture.Extensions.Caching.Tests/QueryCachingBehaviorTests.cs
+++ b/tests/CleanArchitecture.Extensions.Caching.Tests/QueryCachingBehaviorTests.cs
@@ -243,6 +243,26 @@ public async Task Uses_ttl_override_by_request_type()
Assert.Equal(TimeSpan.FromMinutes(1), cached!.Options?.AbsoluteExpirationRelativeToNow);
}
+ [Fact]
+ public async Task Bypasses_caching_when_hash_generation_fails()
+ {
+ var cache = CreateCache();
+ var behavior = CreateBehavior(cache);
+ var callCount = 0;
+ var request = new FaultyQuery(() => 1);
+
+ RequestHandlerDelegate next = _ =>
+ {
+ callCount++;
+ return Task.FromResult("value");
+ };
+
+ await behavior.Handle(request, next, CancellationToken.None);
+ await behavior.Handle(request, next, CancellationToken.None);
+
+ Assert.Equal(2, callCount);
+ }
+
private sealed record TestQuery(int Id) : IRequest, ICacheableQuery;
[CacheableQuery]
@@ -251,4 +271,6 @@ private sealed record AttributeQuery(int Id) : IRequest;
private sealed record NullableQuery(int Id) : IRequest, ICacheableQuery;
private sealed record TestCommand(int Id) : IRequest;
+
+ private sealed record FaultyQuery(Func Factory) : IRequest, ICacheableQuery;
}