Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
using CleanArchitecture.Extensions.Caching.Keys;

namespace CleanArchitecture.Extensions.Caching.Abstractions;

/// <summary>
/// Provides a custom cache hash for requests that cannot be deterministically serialized.
/// </summary>
public interface ICacheKeyProvider
{
/// <summary>
/// Returns a deterministic hash used to build cache keys.
/// </summary>
/// <param name="keyFactory">Cache key factory to help create component hashes.</param>
/// <returns>A canonical hash string.</returns>
string GetCacheHash(ICacheKeyFactory keyFactory);
}
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,17 @@ public async Task<TResponse> Handle(TRequest request, RequestHandlerDelegate<TRe
return await next().ConfigureAwait(false);
}

var key = BuildKey(request);
CacheKey key;
try
{
key = BuildKey(request);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to build cache key for {Request}; bypassing cache.", typeof(TRequest).Name);
return await next().ConfigureAwait(false);
}

var cached = await _cache.GetAsync<TResponse>(key, cancellationToken).ConfigureAwait(false);
if (cached is not null)
{
Expand Down Expand Up @@ -111,7 +121,9 @@ public async Task<TResponse> Handle(TRequest request, RequestHandlerDelegate<TRe
private CacheKey BuildKey(TRequest request)
{
var resource = _behaviorOptions.ResourceNameSelector?.Invoke(request) ?? typeof(TRequest).Name;
var hash = _behaviorOptions.HashFactory?.Invoke(request) ?? _keyFactory.CreateHash(request);
var hash = request is ICacheKeyProvider provider
? provider.GetCacheHash(_keyFactory)
: _behaviorOptions.HashFactory?.Invoke(request) ?? _keyFactory.CreateHash(request);
return _cacheScope.Create(resource, hash);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@
using CleanArchitecture.Extensions.Caching.Options;
using CleanArchitecture.Extensions.Caching.Serialization;
using MediatR;
using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Options;

namespace CleanArchitecture.Extensions.Caching;

Expand Down Expand Up @@ -48,7 +50,30 @@ public static IServiceCollection AddCleanArchitectureCaching(
// Call AddCleanArchitectureMultitenancyCaching (Multitenancy.Caching package) to bind cache scopes to tenant context.
services.TryAddScoped<ICacheScope, DefaultCacheScope>();
services.TryAddSingleton<ICache>(sp =>
ActivatorUtilities.CreateInstance<MemoryCacheAdapter>(sp, sp.GetServices<ICacheSerializer>()));
{
var options = sp.GetRequiredService<IOptions<CachingOptions>>().Value;
var serializers = sp.GetServices<ICacheSerializer>();
var distributedCache = sp.GetService<IDistributedCache>();

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<DistributedCacheAdapter>(sp, serializers);
}

return ActivatorUtilities.CreateInstance<MemoryCacheAdapter>(sp, serializers);
});
services.TryAddSingleton<DistributedCacheAdapter>(sp =>
ActivatorUtilities.CreateInstance<DistributedCacheAdapter>(sp, sp.GetServices<ICacheSerializer>()));

Expand Down
22 changes: 22 additions & 0 deletions src/CleanArchitecture.Extensions.Caching/Options/CacheBackend.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
namespace CleanArchitecture.Extensions.Caching.Options;

/// <summary>
/// Defines the cache backend selection strategy.
/// </summary>
public enum CacheBackend
{
/// <summary>
/// Use a distributed cache when a non-memory implementation is available; otherwise fallback to memory.
/// </summary>
Auto = 0,

/// <summary>
/// Always use the in-memory cache.
/// </summary>
Memory = 1,

/// <summary>
/// Always use the distributed cache.
/// </summary>
Distributed = 2
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ public sealed class CachingOptions
/// </summary>
public bool Enabled { get; set; } = true;

/// <summary>
/// Gets or sets the cache backend selection strategy.
/// </summary>
public CacheBackend Backend { get; set; } = CacheBackend.Auto;

/// <summary>
/// Gets or sets the default namespace applied to cache keys to avoid collisions across applications.
/// </summary>
Expand Down
8 changes: 8 additions & 0 deletions src/CleanArchitecture.Extensions.Caching/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -72,6 +73,13 @@ public record GetTodosQuery : IRequest<TodosVm>;

// or
public record GetUserQuery(int Id) : IRequest<UserDto>, 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<int> Factory) : IRequest<ReportDto>, ICacheableQuery, ICacheKeyProvider
{
public string GetCacheHash(ICacheKeyFactory keyFactory) => keyFactory.CreateHash(new { Version = 1 });
}
```

## Step 5 - What to expect
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
@@ -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<ICache>();

Assert.IsType<MemoryCacheAdapter>(cache);
}

[Fact]
public void Uses_distributed_cache_when_non_memory_distributed_is_registered()
{
var services = new ServiceCollection();
services.AddLogging();

services.AddCleanArchitectureCaching();
services.AddSingleton<IDistributedCache, FakeDistributedCache>();

using var provider = services.BuildServiceProvider();

var cache = provider.GetRequiredService<ICache>();

Assert.IsType<DistributedCacheAdapter>(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<InvalidOperationException>(() => provider.GetRequiredService<ICache>());
}

private sealed class FakeDistributedCache : IDistributedCache
{
public byte[]? Get(string key) => null;
public Task<byte[]?> GetAsync(string key, CancellationToken token = default) => Task.FromResult<byte[]?>(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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<FaultyQuery, string>(cache);
var callCount = 0;
var request = new FaultyQuery(() => 1);

RequestHandlerDelegate<string> 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<string>, ICacheableQuery;

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

private sealed record TestCommand(int Id) : IRequest<string>;

private sealed record FaultyQuery(Func<int> Factory) : IRequest<string>, ICacheableQuery;
}
Loading