Skip to content

Commit 50aedba

Browse files
committed
feat: add distributed backend selection and safe query hashing
auto-select distributed cache when available or requested, expose cache backend option, and introduce ICacheKeyProvider with hash failure fallback to avoid pipeline breaks. include tests/docs for new behavior.
1 parent 2aebd5b commit 50aedba

9 files changed

Lines changed: 185 additions & 3 deletions

File tree

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
using CleanArchitecture.Extensions.Caching.Keys;
2+
3+
namespace CleanArchitecture.Extensions.Caching.Abstractions;
4+
5+
/// <summary>
6+
/// Provides a custom cache hash for requests that cannot be deterministically serialized.
7+
/// </summary>
8+
public interface ICacheKeyProvider
9+
{
10+
/// <summary>
11+
/// Returns a deterministic hash used to build cache keys.
12+
/// </summary>
13+
/// <param name="keyFactory">Cache key factory to help create component hashes.</param>
14+
/// <returns>A canonical hash string.</returns>
15+
string GetCacheHash(ICacheKeyFactory keyFactory);
16+
}

src/CleanArchitecture.Extensions.Caching/Behaviors/QueryCachingBehavior.cs

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,17 @@ public async Task<TResponse> Handle(TRequest request, RequestHandlerDelegate<TRe
5151
return await next().ConfigureAwait(false);
5252
}
5353

54-
var key = BuildKey(request);
54+
CacheKey key;
55+
try
56+
{
57+
key = BuildKey(request);
58+
}
59+
catch (Exception ex)
60+
{
61+
_logger.LogWarning(ex, "Failed to build cache key for {Request}; bypassing cache.", typeof(TRequest).Name);
62+
return await next().ConfigureAwait(false);
63+
}
64+
5565
var cached = await _cache.GetAsync<TResponse>(key, cancellationToken).ConfigureAwait(false);
5666
if (cached is not null)
5767
{
@@ -111,7 +121,9 @@ public async Task<TResponse> Handle(TRequest request, RequestHandlerDelegate<TRe
111121
private CacheKey BuildKey(TRequest request)
112122
{
113123
var resource = _behaviorOptions.ResourceNameSelector?.Invoke(request) ?? typeof(TRequest).Name;
114-
var hash = _behaviorOptions.HashFactory?.Invoke(request) ?? _keyFactory.CreateHash(request);
124+
var hash = request is ICacheKeyProvider provider
125+
? provider.GetCacheHash(_keyFactory)
126+
: _behaviorOptions.HashFactory?.Invoke(request) ?? _keyFactory.CreateHash(request);
115127
return _cacheScope.Create(resource, hash);
116128
}
117129

src/CleanArchitecture.Extensions.Caching/DependencyInjectionExtensions.cs

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,11 @@
44
using CleanArchitecture.Extensions.Caching.Options;
55
using CleanArchitecture.Extensions.Caching.Serialization;
66
using MediatR;
7+
using Microsoft.Extensions.Caching.Distributed;
78
using Microsoft.Extensions.Caching.Memory;
89
using Microsoft.Extensions.DependencyInjection;
910
using Microsoft.Extensions.DependencyInjection.Extensions;
11+
using Microsoft.Extensions.Options;
1012

1113
namespace CleanArchitecture.Extensions.Caching;
1214

@@ -48,7 +50,30 @@ public static IServiceCollection AddCleanArchitectureCaching(
4850
// Call AddCleanArchitectureMultitenancyCaching (Multitenancy.Caching package) to bind cache scopes to tenant context.
4951
services.TryAddScoped<ICacheScope, DefaultCacheScope>();
5052
services.TryAddSingleton<ICache>(sp =>
51-
ActivatorUtilities.CreateInstance<MemoryCacheAdapter>(sp, sp.GetServices<ICacheSerializer>()));
53+
{
54+
var options = sp.GetRequiredService<IOptions<CachingOptions>>().Value;
55+
var serializers = sp.GetServices<ICacheSerializer>();
56+
var distributedCache = sp.GetService<IDistributedCache>();
57+
58+
var useDistributed = options.Backend switch
59+
{
60+
CacheBackend.Distributed => true,
61+
CacheBackend.Memory => false,
62+
_ => distributedCache is not null && distributedCache is not MemoryDistributedCache
63+
};
64+
65+
if (useDistributed)
66+
{
67+
if (distributedCache is null)
68+
{
69+
throw new InvalidOperationException("CachingOptions.Backend is set to Distributed but no IDistributedCache is registered.");
70+
}
71+
72+
return ActivatorUtilities.CreateInstance<DistributedCacheAdapter>(sp, serializers);
73+
}
74+
75+
return ActivatorUtilities.CreateInstance<MemoryCacheAdapter>(sp, serializers);
76+
});
5277
services.TryAddSingleton<DistributedCacheAdapter>(sp =>
5378
ActivatorUtilities.CreateInstance<DistributedCacheAdapter>(sp, sp.GetServices<ICacheSerializer>()));
5479

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
namespace CleanArchitecture.Extensions.Caching.Options;
2+
3+
/// <summary>
4+
/// Defines the cache backend selection strategy.
5+
/// </summary>
6+
public enum CacheBackend
7+
{
8+
/// <summary>
9+
/// Use a distributed cache when a non-memory implementation is available; otherwise fallback to memory.
10+
/// </summary>
11+
Auto = 0,
12+
13+
/// <summary>
14+
/// Always use the in-memory cache.
15+
/// </summary>
16+
Memory = 1,
17+
18+
/// <summary>
19+
/// Always use the distributed cache.
20+
/// </summary>
21+
Distributed = 2
22+
}

src/CleanArchitecture.Extensions.Caching/Options/CachingOptions.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,11 @@ public sealed class CachingOptions
1010
/// </summary>
1111
public bool Enabled { get; set; } = true;
1212

13+
/// <summary>
14+
/// Gets or sets the cache backend selection strategy.
15+
/// </summary>
16+
public CacheBackend Backend { get; set; } = CacheBackend.Auto;
17+
1318
/// <summary>
1419
/// Gets or sets the default namespace applied to cache keys to avoid collisions across applications.
1520
/// </summary>

src/CleanArchitecture.Extensions.Caching/README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ public static void AddInfrastructureServices(this IHostApplicationBuilder builde
2929
{
3030
options.DefaultNamespace = "MyApp";
3131
options.MaxEntrySizeBytes = 256 * 1024;
32+
// Set Backend = CacheBackend.Distributed to force shared cache when IDistributedCache is configured.
3233
}, queryOptions =>
3334
{
3435
queryOptions.DefaultTtl = TimeSpan.FromMinutes(5);
@@ -72,6 +73,13 @@ public record GetTodosQuery : IRequest<TodosVm>;
7273

7374
// or
7475
public record GetUserQuery(int Id) : IRequest<UserDto>, ICacheableQuery;
76+
77+
// If your query cannot be serialized for hashing (e.g., contains delegates or HttpContext),
78+
// implement ICacheKeyProvider to supply a deterministic hash:
79+
public record GetReportQuery(Func<int> Factory) : IRequest<ReportDto>, ICacheableQuery, ICacheKeyProvider
80+
{
81+
public string GetCacheHash(ICacheKeyFactory keyFactory) => keyFactory.CreateHash(new { Version = 1 });
82+
}
7583
```
7684

7785
## Step 5 - What to expect

tests/CleanArchitecture.Extensions.Caching.Tests/CachingOptionsTests.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ public void Default_options_set_expected_defaults()
1010
var options = CachingOptions.Default;
1111

1212
Assert.True(options.Enabled);
13+
Assert.Equal(CacheBackend.Auto, options.Backend);
1314
Assert.Equal("CleanArchitectureExtensions", options.DefaultNamespace);
1415
Assert.NotNull(options.DefaultEntryOptions);
1516
Assert.Equal(CachePriority.Normal, options.DefaultEntryOptions.Priority);
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
using System.Linq;
2+
using CleanArchitecture.Extensions.Caching;
3+
using CleanArchitecture.Extensions.Caching.Abstractions;
4+
using CleanArchitecture.Extensions.Caching.Adapters;
5+
using CleanArchitecture.Extensions.Caching.Options;
6+
using Microsoft.Extensions.Caching.Distributed;
7+
using Microsoft.Extensions.DependencyInjection;
8+
9+
namespace CleanArchitecture.Extensions.Caching.Tests;
10+
11+
public class DependencyInjectionExtensionsTests
12+
{
13+
[Fact]
14+
public void Uses_memory_cache_adapter_by_default()
15+
{
16+
var services = new ServiceCollection();
17+
services.AddLogging();
18+
19+
services.AddCleanArchitectureCaching();
20+
21+
using var provider = services.BuildServiceProvider();
22+
23+
var cache = provider.GetRequiredService<ICache>();
24+
25+
Assert.IsType<MemoryCacheAdapter>(cache);
26+
}
27+
28+
[Fact]
29+
public void Uses_distributed_cache_when_non_memory_distributed_is_registered()
30+
{
31+
var services = new ServiceCollection();
32+
services.AddLogging();
33+
34+
services.AddCleanArchitectureCaching();
35+
services.AddSingleton<IDistributedCache, FakeDistributedCache>();
36+
37+
using var provider = services.BuildServiceProvider();
38+
39+
var cache = provider.GetRequiredService<ICache>();
40+
41+
Assert.IsType<DistributedCacheAdapter>(cache);
42+
}
43+
44+
[Fact]
45+
public void Throws_when_distributed_backend_selected_without_distributed_cache()
46+
{
47+
var services = new ServiceCollection();
48+
services.AddLogging();
49+
50+
services.AddCleanArchitectureCaching(options => options.Backend = CacheBackend.Distributed);
51+
// Remove the default distributed cache registration to simulate misconfiguration.
52+
var descriptor = services.First(d => d.ServiceType == typeof(IDistributedCache));
53+
services.Remove(descriptor);
54+
55+
using var provider = services.BuildServiceProvider();
56+
57+
Assert.Throws<InvalidOperationException>(() => provider.GetRequiredService<ICache>());
58+
}
59+
60+
private sealed class FakeDistributedCache : IDistributedCache
61+
{
62+
public byte[]? Get(string key) => null;
63+
public Task<byte[]?> GetAsync(string key, CancellationToken token = default) => Task.FromResult<byte[]?>(null);
64+
public void Refresh(string key) { }
65+
public Task RefreshAsync(string key, CancellationToken token = default) => Task.CompletedTask;
66+
public void Remove(string key) { }
67+
public Task RemoveAsync(string key, CancellationToken token = default) => Task.CompletedTask;
68+
public void Set(string key, byte[] value, DistributedCacheEntryOptions options) { }
69+
public Task SetAsync(string key, byte[] value, DistributedCacheEntryOptions options, CancellationToken token = default) => Task.CompletedTask;
70+
}
71+
}

tests/CleanArchitecture.Extensions.Caching.Tests/QueryCachingBehaviorTests.cs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,26 @@ public async Task Uses_ttl_override_by_request_type()
243243
Assert.Equal(TimeSpan.FromMinutes(1), cached!.Options?.AbsoluteExpirationRelativeToNow);
244244
}
245245

246+
[Fact]
247+
public async Task Bypasses_caching_when_hash_generation_fails()
248+
{
249+
var cache = CreateCache();
250+
var behavior = CreateBehavior<FaultyQuery, string>(cache);
251+
var callCount = 0;
252+
var request = new FaultyQuery(() => 1);
253+
254+
RequestHandlerDelegate<string> next = _ =>
255+
{
256+
callCount++;
257+
return Task.FromResult("value");
258+
};
259+
260+
await behavior.Handle(request, next, CancellationToken.None);
261+
await behavior.Handle(request, next, CancellationToken.None);
262+
263+
Assert.Equal(2, callCount);
264+
}
265+
246266
private sealed record TestQuery(int Id) : IRequest<string>, ICacheableQuery;
247267

248268
[CacheableQuery]
@@ -251,4 +271,6 @@ private sealed record AttributeQuery(int Id) : IRequest<string>;
251271
private sealed record NullableQuery(int Id) : IRequest<string?>, ICacheableQuery;
252272

253273
private sealed record TestCommand(int Id) : IRequest<string>;
274+
275+
private sealed record FaultyQuery(Func<int> Factory) : IRequest<string>, ICacheableQuery;
254276
}

0 commit comments

Comments
 (0)