DSoftStudio Mediator

← Back to Documentation

HybridCache Integration

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.

Installation

dotnet add package DSoftStudio.Mediator.HybridCache

Registration

services
    .AddMediator()
    .RegisterMediatorHandlers()
    .AddHybridCache()                   // Microsoft's built-in registration
    .AddMediatorHybridCache()           // Registers CachingBehavior<,>
    .PrecompilePipelines()
    .PrecompileNotifications()
    .PrecompileStreams();

Mark Requests as Cacheable

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.

Adding Redis as L2

services.AddStackExchangeRedisCache(options =>
    options.Configuration = "localhost:6379");

HybridCache automatically uses the registered IDistributedCache as L2 — no changes to your mediator code.

Cache Invalidation

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

Caching One Request Instead of All of Them

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.

What the Cache Key Must Contain

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

Order Against Validation

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.

Behavior Summary

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

Native AOT and trimming needs a serializer

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.

See Also