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; }