The DSoftStudio.Mediator.HybridCache package provides automatic request caching via Microsoft’s HybridCache — multi-layer caching (L1 memory + L2 distributed), stampede prevention, and serialization out of the box.
dotnet add package DSoftStudio.Mediator.HybridCache
services
.AddMediator()
.RegisterMediatorHandlers()
.AddHybridCache() // Microsoft's built-in registration
.AddMediatorHybridCache() // Registers CachingBehavior<,>
.PrecompilePipelines()
.PrecompileNotifications()
.PrecompileStreams();
Implement ICachedRequest on any request to opt-in to caching:
public record GetProduct(Guid Id) : IQuery<ProductDto>, ICachedRequest
{
public string CacheKey => $"products:{Id}";
public TimeSpan Duration => TimeSpan.FromMinutes(5); // default: 60s
}
That’s it — the CachingBehavior intercepts the pipeline, checks for ICachedRequest, and uses HybridCache.GetOrCreateAsync() to cache the result. Requests that don’t implement ICachedRequest are passed straight through by a single is not ICachedRequest type-check.
That check is not the whole cost, though. AddMediatorHybridCache() registers the behavior as an open generic, so it joins the pipeline of every request in the application, and a request that would otherwise have no pipeline components at all now has a chain built for it instead of being dispatched directly to its handler. Register per request pair when caching covers a known handful of queries, and every other request keeps its direct dispatch.
services.AddStackExchangeRedisCache(options =>
options.Configuration = "localhost:6379");
HybridCache automatically uses the registered IDistributedCache as L2 — no changes to your mediator code.
Use HybridCache.RemoveAsync() directly in command handlers:
public class DeleteProductHandler : ICommandHandler<DeleteProduct, Unit>
{
private readonly HybridCache _cache;
public DeleteProductHandler(HybridCache cache) => _cache = cache;
public async ValueTask<Unit> Handle(DeleteProduct request, CancellationToken ct)
{
// ... delete from database ...
await _cache.RemoveAsync($"products:{request.Id}", ct);
return Unit.Value;
}
}
The generic overload registers the behavior closed over one (request, response) pair, so only that pair gets a pipeline chain:
services
.AddMediatorHybridCache<GetProduct, ProductDto>()
.PrecompilePipelines();
TRequest is constrained to ICachedRequest, so registering a request that never opted in does not compile — a mistake the open form cannot catch.
The two forms are alternatives, not layers. If the open registration is already present the closed call stands down, because a second descriptor would put the behavior in that chain twice and the outer lookup would re-enter the inner one on the same key.
CacheKey reaches HybridCache verbatim. Nothing about the request type or its properties is mixed in, so two request types returning the same string share one entry — put the request type in the key.
Everything the response depends on belongs there too, including what the handler reads from ambient state rather than from the request. Stampede prevention means concurrent callers on one key share a single handler execution: the first caller’s handler runs and everyone waiting receives its result. A handler that resolves the current tenant or user from IHttpContextAccessor or an AsyncLocal therefore serves the first caller’s answer to the rest, for the lifetime of the entry.
public record GetDashboard(Guid Id, string TenantId) : IQuery<DashboardDto>, ICachedRequest
{
// The tenant is part of what the response depends on, so it is part of the key.
public string CacheKey => $"{nameof(GetDashboard)}:{TenantId}:{Id}";
}
Pipeline behaviors run in registration order, and when a request is both cached and validated that order decides whether a cache hit is validated at all.
// Validation outer: every dispatch is validated, hits included.
services.AddMediatorFluentValidation();
services.AddMediatorHybridCache();
// Caching outer: a hit returns before validation is ever reached.
services.AddMediatorHybridCache();
services.AddMediatorFluentValidation();
With caching registered first, the first dispatch populates the entry and every later one is served from it — without running the validators. Nothing warns; the rules are simply skipped. If validation is authorization, a permission check, or anything else that can change its answer between two calls with the same key, register validation before caching.
An invalid request never populates the cache in either order: the handler does not run, so there is no value to store, and the next dispatch is rejected again rather than served a cached failure.
| Scenario | Result |
|---|---|
Request implements ICachedRequest |
Result cached via HybridCache with specified duration |
Request does not implement ICachedRequest |
Pass-through — handler executes normally |
| Same cache key within TTL | Cached result returned, handler not invoked |
| Concurrent requests for same key | Stampede prevention — one execution, all callers share result |
| Registered open-generic | Every request in the application gets a pipeline chain |
| Registered per pair | Only that pair gets a chain; every other request keeps its direct dispatch |
| Registered before validation | A cache hit is served without validating the request |
HybridCache serializes everything it caches, and AddHybridCache pre-registers a serializer for
exactly two types: string and byte[]. Every other response type reaches reflection-based
System.Text.Json — which Native AOT and trimming disable.
The failure is well hidden. The build succeeds, the AOT publish succeeds, the application starts, and
then the first dispatch of a cacheable request throws from inside Microsoft’s serializer, naming
neither the request nor the remedy. A string response caches fine, so a project can look healthy
until its first DTO.
The generator raises DSOFT012 at build time instead, naming the type. To fix it, declare a context listing the cached response types and register it as the cache’s serializer options:
[JsonSerializable(typeof(ProductDto))]
[JsonSerializable(typeof(OrderDto))] // one line per cached response type
internal sealed partial class AppJsonContext : JsonSerializerContext;
services.AddKeyedSingleton<JsonSerializerOptions>(
typeof(IHybridCacheSerializer<>),
new JsonSerializerOptions { TypeInfoResolver = AppJsonContext.Default });
Both details matter: without the explicit <JsonSerializerOptions> type argument the call is
ambiguous and will not compile, and a registration that is not keyed on the open generic is
silently ignored. You can also register an IHybridCacheSerializer<T> per type if you want a format
other than JSON.
[ImmutableObject(true)]is not a workaround, though it looks like one. Verified in a native binary: a sealed record carrying it throws exactly as an unmarked type does, because the serializer-free path requires writes disabled on both cache tiers — which caches nothing at all. What the marker does change is that callers get the same instance rather than a copy per read, so on a DTO anything later mutates, that mutation is shared for the whole entry lifetime.
IQuery<T> constraintIQuery<T> marker for read-only operationsCachingBehavior wraps the pipeline