Skip to main content

Caching

Excalibur.Dispatch.Caching integrates caching directly into the dispatch pipeline. Actions that implement ICacheable<T> are automatically cached, with support for memory, distributed, and hybrid cache modes.

Before You Start​

  • .NET 10.0
  • Install the required package:
    dotnet add package Excalibur.Dispatch.Caching
  • Familiarity with pipeline concepts and middleware

Packages​

PackagePurpose
Excalibur.Dispatch.CachingPipeline caching middleware, ICacheable<T>, ICacheInvalidationService, attribute-based caching (backed by HybridCache)
Excalibur.CachingAdaptive TTL strategies, CQRS projection cache invalidation

Cache Modes​

Excalibur.Dispatch supports three caching modes out of the box:

ModeBackendUse Case
MemoryIMemoryCache (in-process)Single-instance apps, fastest performance, not shared across servers
DistributedIDistributedCache (Redis, SQL, etc.)Multi-instance deployments, shared across servers, network latency cost
HybridMemory L1 + Distributed L2Best of both: local speed with shared consistency via Microsoft.Extensions.Caching.Hybrid

Setup​

In-Memory Only​

Best for single-instance applications or development:

using Microsoft.Extensions.DependencyInjection;

services.AddDispatchMemoryCaching(
configureMemory: options =>
{
options.SizeLimit = 1024; // Max entries
},
configureCaching: options =>
{
options.Enabled = true;
options.Behavior.DefaultExpiration = TimeSpan.FromMinutes(5);
});
Tag invalidation needs Redis to be exact

Cache tag invalidation is atomic only on Redis. On any other IDistributedCache backend (SQL Server, for example) the tag-to-keys set is maintained with a read-modify-write, because IDistributedCache offers no atomic set-add. Two instances registering different keys under the same tag at the same time can lose one of them, and a key that falls out of the set is not invalidated when its tag is — that entry serves stale data until its own expiry elapses.

Register a Redis connection if you rely on tag invalidation being exact. Otherwise keep entry lifetimes short enough that the staleness window is acceptable. The framework logs a warning at startup when it selects the best-effort tracker, so this is visible rather than silent.

Redis (Distributed)​

For multi-instance deployments with shared cache:

services.AddDispatchRedisCaching(
configureRedis: options =>
{
options.Configuration = "localhost:6379,abortConnect=false";
options.InstanceName = "MyApp:";
},
configureCaching: options =>
{
options.Enabled = true;
options.Distributed.KeyPrefix = "dispatch:";
options.Distributed.MaxRetryAttempts = 3;
options.Distributed.RetryDelay = TimeSpan.FromMilliseconds(100);
});

With appsettings.json:

{
"Redis": {
"Configuration": "localhost:6379,abortConnect=false,ssl=true,password=your-password",
"InstanceName": "MyApp:"
}
}

Hybrid (Memory + Redis)​

Recommended for production. Checks local memory first, falls back to Redis:

services.AddDispatchHybridCaching(
configureHybrid: options =>
{
options.MaximumPayloadBytes = 1024 * 1024; // 1MB max cached item
options.DefaultEntryOptions = new()
{
Expiration = TimeSpan.FromMinutes(10),
LocalCacheExpiration = TimeSpan.FromMinutes(2),
};
},
configureRedis: options =>
{
options.Configuration = "localhost:6379,abortConnect=false";
options.InstanceName = "MyApp:";
},
configureCaching: options =>
{
options.Enabled = true;
});

Builder Pattern​

All modes also work through IDispatchBuilder:

dispatch.UseCaching()
.WithCachingOptions(options =>
{
options.Enabled = true;
options.CacheMode = CacheMode.Hybrid;
options.Behavior.DefaultExpiration = TimeSpan.FromMinutes(10);
options.Behavior.UseSlidingExpiration = true;
options.Behavior.JitterRatio = 0.10; // 10% TTL jitter to avoid stampedes
});

ICacheable Actions​

Make actions cacheable by implementing ICacheable<T>. Since ICacheable<T> extends IDispatchAction<T>, your action is automatically a dispatch action that returns a result:

using Excalibur.Dispatch.Caching;

public class GetProductAction : ICacheable<ProductDto>
{
public Guid ProductId { get; set; }

// Cache for 5 minutes
public int ExpirationSeconds => 300;

// Unique cache key for this action
public string GetCacheKey()
=> $"product:{ProductId}";

// Tags for grouped invalidation
public string[] GetCacheTags()
=> [$"product:{ProductId}", "products"];

// Conditional caching (receives the handler result)
public bool ShouldCache(object? result)
=> ProductId != Guid.Empty && result is not null;
}

The pipeline automatically checks the cache before executing the handler. On a cache hit, the handler is skipped and the cached result is returned.

ICacheable Members​

MemberPurpose
ExpirationSecondsHow long to cache the result (default: 60s)
GetCacheKey()Unique key identifying this specific request
GetCacheTags()Tags for grouped invalidation
ShouldCache(object? result)Whether to cache this particular result

Key derivation and fail-open behavior​

Caching is a cross-cutting concern, so it must never break the underlying operation. When the pipeline cannot derive a stable cache key for an action, it fails open: it skips the cache and invokes the handler directly, exactly as if caching were disabled for that one request. This happens when:

  • An ICacheable<T> action's GetCacheKey() cannot be resolved (for example, reflection over the action fails).
  • A non-ICacheable action cached via [CacheResult] cannot be serialized to produce a content-based key.
  • The action's runtime type has no resolvable type name.

In every case the request still executes — you simply get a cache miss instead of an error. The framework never fabricates a substitute key (such as an identity hash), because a guessed key could produce a false cross-request cache hit and return one caller's data to another. Skipped derivations are logged at Debug level.

Custom cache key builders​

Set the CacheKeyBuilder option to replace the built-in DefaultCacheKeyBuilder with your own ICacheKeyBuilder:

using System;
using Excalibur.Dispatch;
using Excalibur.Dispatch.Caching;

public sealed class ProductCacheKeyBuilder : ICacheKeyBuilder
{
public string? CreateKey(IDispatchAction action, IMessageContext context)
{
// Return null to signal "no cache identity" — the pipeline then skips caching
// for this request. Implementations MUST be infallible: return null rather than
// throwing when a key cannot be derived.
if (action is not GetProductAction product || product.ProductId == Guid.Empty)
{
return null; // do not cache
}

var tenant = context.GetTenantId() ?? "global";
return $"{tenant}:product:{product.ProductId}";
}

// Direct-invalidation overload: builds the stored key for a logical key under a
// tenant/user identity, without a live IMessageContext. Apply the SAME identity
// folding here as in CreateKey(action, context) so a direct invalidation removes
// the exact key a cacheable query stored. This overload performs no serialization
// and is AOT-/trimming-safe.
public string CreateKey(string logicalKey, string? tenantId, string? userId)
{
var tenant = tenantId ?? "global";
return $"{tenant}:{logicalKey}";
}
}

ICacheKeyBuilder has two members; a custom builder must implement both:

MemberPurpose
string? CreateKey(IDispatchAction action, IMessageContext context)Derives the cache key for an in-flight request. Returns null to signal "do not cache".
string CreateKey(string logicalKey, string? tenantId, string? userId)Builds the stored key for a logical key under a tenant/user identity, without a live IMessageContext. Used for direct-key invalidation — turns a raw logical key into the exact stored key to remove. Performs no serialization (AOT-/trimming-safe).

The action/context overload returns string?:

  • A non-null key is used to look up and store the cached result.
  • A null result means "do not cache" — the pipeline bypasses the cache and invokes the handler.

This overload must be infallible for a "cannot derive a key" condition: return null instead of throwing, so a key-building failure degrades to a cache miss rather than failing the request.

Apply the same identity transform in both overloads. A raw logical key can never equal the stored, identity-folded key, so the invalidation overload must fold tenant/user exactly as the request overload does — otherwise direct invalidation silently misses the entry it meant to remove.

Attribute-Based Caching​

Use [CacheResult] to add caching without implementing ICacheable<T>:

using Excalibur.Dispatch.Caching;

[CacheResult(
ExpirationSeconds = 600,
Tags = new[] { "products" },
OnlyIfSuccess = true,
IgnoreNullResult = true)]
public class ListProductsAction : IDispatchAction<IReadOnlyList<ProductDto>>
{
public string Category { get; set; } = string.Empty;
}

CacheResultAttribute Properties​

PropertyTypeDefaultDescription
ExpirationSecondsint0 (use default)Cache duration in seconds
Tagsstring[][]Tags for grouped invalidation
OnlyIfSuccessbooltrueOnly cache successful results
IgnoreNullResultbooltrueSkip caching when result is null

A Cached Handler Must Have No Side Effects​

This applies to [CacheResult] and to ICacheable<T> equally, and it is the one rule that will bite you if it is missed.

A cached handler is run as the value factory of HybridCache, and a value factory is not invoked once per dispatch:

What happensHandler invocations
Cache hitnone — the stored value is returned and your handler never runs
N concurrent dispatches of the same keyone — they are collapsed onto a single in-flight factory call
Background refreshone extra, on a task no dispatch is awaiting

So the invocation count is unrelated to the dispatch count in both directions, and no configuration changes that — it is how a single-flight cache avoids stampeding your backend, which is the reason to use one.

Read it as: a cached handler answers "what is the value for this key" and nothing else. Anything that must happen exactly once per request — a write, an outbound message, a counter, an audit entry — belongs outside the cached handler:

// WRONG: the audit entry is skipped on every cache hit and can be written twice on a refresh.
[CacheResult(ExpirationSeconds = 600)]
public sealed class GetCustomerAction : IDispatchAction<CustomerDto> { public Guid Id { get; init; } }

public sealed class GetCustomerHandler(ICustomerRepository repo, IAuditLog audit)
: IActionHandler<GetCustomerAction, CustomerDto>
{
public async Task<CustomerDto> HandleAsync(GetCustomerAction action, CancellationToken cancellationToken)
{
await audit.RecordAccessAsync(action.Id, cancellationToken); // side effect inside the value factory
return await repo.GetAsync(action.Id, cancellationToken);
}
}

Keep the handler a pure read, and put the per-request work in its own middleware or in the caller — where it runs once per dispatch regardless of whether the read was served from cache.

Cache Invalidation​

By Key or Tag​

Invalidate caches by key or tag using ICacheInvalidationService:

using Excalibur.Dispatch.Caching;

public class UpdateProductHandler : IActionHandler<UpdateProductAction>
{
private readonly ICacheInvalidationService _cache;

public UpdateProductHandler(ICacheInvalidationService cache)
{
_cache = cache;
}

public async Task HandleAsync(
UpdateProductAction action,
CancellationToken ct)
{
// Update the product...

// Invalidate specific entry
await _cache.InvalidateKeysAsync([$"product:{action.ProductId}"], ct);

// Invalidate all product listings
await _cache.InvalidateTagsAsync(["products"], ct);
}
}

For direct, low-level access you can also inject HybridCache (Microsoft.Extensions.Caching.Hybrid) and call RemoveAsync / RemoveByTagAsync.

Bulk Invalidation Service​

For cross-cutting invalidation, inject ICacheInvalidationService:

public class ProductCatalogRefreshHandler
{
private readonly ICacheInvalidationService _invalidation;

public ProductCatalogRefreshHandler(ICacheInvalidationService invalidation)
{
_invalidation = invalidation;
}

public async Task HandleAsync(CatalogRefreshedEvent evt, CancellationToken ct)
{
// Invalidate all product-related caches at once
await _invalidation.InvalidateTagsAsync(["products", "categories"], ct);

// Or specific keys
await _invalidation.InvalidateKeysAsync(
evt.UpdatedProductIds.Select(id => $"product:{id}"),
ct);
}
}

CQRS Projection Invalidation​

When using event sourcing with projections, the Excalibur.Caching package provides automatic cache invalidation when projections update:

dotnet add package Excalibur.Caching
// Register projection caching after dispatch caching.
// AddProjectionCaching(...) is the canonical extension on IEventSourcingBuilder
// (the pre-unification AddExcaliburProjectionCaching aggregator
// is internal to Excalibur.Caching).
builder.Services.AddDispatch(dispatch =>
{
dispatch.UseCaching(o => o.Enabled = true);
});
builder.Services.AddExcalibur(excalibur => excalibur
.AddEventSourcing(es => es.AddProjectionCaching()));

Implement IProjectionTagResolver<T> to map domain events to cache tags:

public class ProductUpdatedTagResolver : IProjectionTagResolver<ProductUpdatedEvent>
{
public IEnumerable<string> GetTags(ProductUpdatedEvent message)
{
yield return $"product:{message.ProductId}";
yield return "products";
}
}

When the projection handler processes ProductUpdatedEvent, the IProjectionCacheInvalidator automatically invalidates cache entries tagged with the resolved tags.

Tag Tracking​

HybridCache (used in Hybrid mode) has native tag-based invalidation support, but that coverage is L1-only. The ICacheTagTracker interface covers the rest: each tag is a single, opaque version stamp. A cache entry written under a tag records that tag's stamp at write time; a read compares the entry's own recorded stamp against the tag's current stamp, and invalidating a tag simply replaces its stamp. Every entry that recorded the old stamp stops matching on its next read and is evicted lazily, at that point — there is no key-set to maintain and no key-to-tag mapping to keep in sync.

ICacheTagTracker​

public interface ICacheTagTracker
{
Task<string> GetOrCreateStampAsync(string tag, CancellationToken cancellationToken);
Task BumpStampAsync(string tag, CancellationToken cancellationToken);
}
MethodPurpose
GetOrCreateStampAsyncResolves a tag's current version stamp, creating one the first time the tag is seen. Used both to embed a tag's stamp into a new entry at write time, and to check an existing entry's recorded stamp against the tag's current one at read time.
BumpStampAsyncInvalidates a tag by replacing its version stamp. Every entry that recorded the old stamp is treated as invalidated on its next read.

A tag with no stamp yet in the backend (nothing has ever invalidated it) and a cache entry with no recorded stamp for one of its own tags (typically an entry written before this mechanism existed) are different conditions: the former is safe to treat as still valid, while the latter cannot be proven valid and is treated as invalidated. Cached data written under the previous key-tracking mechanism requires no migration — it is read as this second, "unprovable" case on its first post-upgrade read (one cache miss), and every subsequent write is stamped normally from then on.

Built-in Implementations​

The correct implementation is selected automatically based on CacheMode and whether a real IDistributedCache is registered:

ImplementationSelected WhenStorage
InMemoryCacheTagTrackerMemory mode, or Distributed/Hybrid without a real distributed cacheIn-process ConcurrentDictionary, bounded by TagTrackerCapacity
DistributedCacheTagTrackerDistributed or Hybrid mode with a real IDistributedCache (not MemoryDistributedCache)One key per tag in the distributed cache; resolving or invalidating a tag is a single atomic Get/Set

You do not need to register ICacheTagTracker manually -- AddDispatchCaching() and UseCaching() handle it automatically. A resolved stamp is memoized per tag for TagStampRefreshInterval (default 5 seconds) before the backend is checked again, so steady-state reads cost no backend round trip; that interval is also the bound on how long an invalidation made on one instance takes to be observed by another.

Fail-open and atomicity

Tag stamp resolution and invalidation are cross-cutting concerns, so they fail open: if the tag-store backend throws while resolving or bumping a stamp, the error is logged and skipped — it is never propagated to the dispatch result, so a tag-store outage cannot break core message handling (cancellation still propagates; a request that cannot be served from cache simply falls through to the handler). Resolving or invalidating a tag is a single-key operation, which is atomic on every IDistributedCache backend — there is no multi-key read-modify-write window.

Capacity Limits​

InMemoryCacheTagTracker is bounded by CacheOptions.TagTrackerCapacity (default: 10,000 distinct tags), to prevent unbounded memory growth when tag names are derived from unbounded input. Increase this value if your application uses more than 10,000 distinct tags:

services.AddDispatchCaching(options =>
{
options.Enabled = true;
options.TagTrackerCapacity = 50_000; // Allow up to 50,000 distinct tags
});

Configuration Reference​

CacheOptions​

PropertyTypeDefaultDescription
EnabledboolfalseMust be explicitly enabled
CacheModeCacheModeHybridMemory, Distributed, or Hybrid
DefaultTagsstring[][]Tags applied to all cached items
TagTrackerCapacityint10,000Max distinct tags tracked by InMemoryCacheTagTracker
TagStampRefreshIntervalTimeSpan5 secHow long a resolved tag version stamp is trusted before re-checking the backend; bounds cross-instance invalidation visibility
TagStampLifetimeTimeSpan1000 daysHow long a tag's version stamp record is retained in the distributed cache backend; must exceed DefaultExpiration
GlobalPolicyIResultCachePolicy?nullCross-cutting cache policy
CacheKeyBuilderICacheKeyBuilder?nullCustom key generation (default: DefaultCacheKeyBuilder)

CacheBehaviorOptions (options.Behavior)​

PropertyTypeDefaultDescription
DefaultExpirationTimeSpan10 minDefault TTL for cached items
UseSlidingExpirationbooltrueReset expiration on access
CacheTimeoutTimeSpan200 msMax wait for a single distributed (L2) backend call. A read that runs longer is treated as a miss and a write that runs longer is dropped, so a slow backend degrades to a cache miss rather than slowing every request. It does not bound handler execution — a cached handler may take as long as its work takes without losing single-flight protection. The deadline is enforced by cancelling the backend call, so it depends on your IDistributedCache honouring its CancellationToken; a backend that ignores cancellation will still block for as long as it takes.
JitterRatiodouble0.10Random TTL variance (0-1) to prevent stampedes
EnableStatisticsboolfalseCollect hit/miss/eviction metrics
EnableCompressionboolfalseCompress values in distributed cache

DistributedCacheOptions (options.Distributed)​

PropertyTypeDefaultDescription
KeyPrefixstring"dispatch:"Prefix for all distributed cache keys
UseBinarySerializationboolfalseBinary serialization (more efficient, less debuggable)
MaxRetryAttemptsint3Retry attempts for distributed operations
RetryDelayTimeSpan100 msDelay between retries

CacheResilienceOptions (options.Resilience)​

PropertyTypeDefaultDescription
EnableFallbackbooltrueFall back to direct execution when cache fails
LogMetricsOnDisposalbooltrueLog performance metrics on shutdown

Circuit Breaker (options.Resilience.CircuitBreaker)​

PropertyTypeDefaultDescription
EnabledbooltrueEnable cache circuit breaker
FailureThresholdint5Consecutive failures to open circuit
FailureWindowTimeSpan1 minTime window for counting failures
BreakDurationTimeSpan30 secHow long circuit stays open
HalfOpenTestLimitint3Test requests in half-open state
HalfOpenSuccessThresholdint2Successes needed to close circuit

Cache Providers​

Custom cache backends implement the standard IDistributedCache abstraction (Microsoft.Extensions.Caching.Distributed) — the framework layers its memory L1, tag tracking, and pipeline middleware on top of whatever IDistributedCache you supply:

using Microsoft.Extensions.Caching.Distributed;

public class CustomDistributedCache : IDistributedCache
{
public byte[]? Get(string key) { /* ... */ }
public Task<byte[]?> GetAsync(string key, CancellationToken token) { /* ... */ }
public void Set(string key, byte[] value, DistributedCacheEntryOptions options) { /* ... */ }
public Task SetAsync(string key, byte[] value, DistributedCacheEntryOptions options,
CancellationToken token) { /* ... */ }
public void Refresh(string key) { /* ... */ }
public Task RefreshAsync(string key, CancellationToken token) { /* ... */ }
public void Remove(string key) { /* ... */ }
public Task RemoveAsync(string key, CancellationToken token) { /* ... */ }
}

Register with the generic distributed caching method:

services.AddDispatchDistributedCaching<CustomDistributedCache>(options =>
{
options.Enabled = true;
options.CacheMode = CacheMode.Distributed;
});

Health Monitoring​

Monitor cache health and performance in production by injecting ICacheHealthMonitor:

public class CacheHealthCheck
{
private readonly ICacheHealthMonitor _monitor;

public CacheHealthCheck(ICacheHealthMonitor monitor)
{
_monitor = monitor;
}

public async Task<bool> CheckAsync(CancellationToken ct)
{
var status = await _monitor.GetHealthStatusAsync(ct);
// status.IsHealthy, status.ResponseTimeMs, status.ConnectionStatus

var perf = _monitor.GetPerformanceSnapshot();
// perf.HitCount, perf.MissCount, perf.ErrorCount

return status.IsHealthy;
}
}

When Redis is registered, CacheHealthMonitor pings Redis via IConnectionMultiplexer.PingAsync() to verify connectivity.

Production Recommendations​

Choose the right mode:

ScenarioRecommended Mode
Single server, low latency requiredMemory
Multiple servers, shared state neededDistributed (Redis)
Multiple servers, best performanceHybrid (default)
Serverless (Azure Functions, AWS Lambda)Distributed (Redis)

TTL jitter: Keep JitterRatio at 0.10 (10%) to avoid cache stampedes where many entries expire simultaneously.

Circuit breaker: Leave enabled in production. If Redis goes down, the circuit breaker opens after 5 failures and the pipeline falls back to direct handler execution for 30 seconds before retrying.

Statistics: Enable Behavior.EnableStatistics in staging/production to track hit rates. Low hit rates may indicate poor key design or too-short TTLs.

Compression: Enable Behavior.EnableCompression for large cached objects in distributed mode to reduce Redis memory usage and network bandwidth.

What's Next​

See Also​

  • Built-in Middleware -- Overview of all built-in middleware including caching integration
  • Auto-Freeze -- Immutable message optimization for improved caching and thread safety
  • Performance Tuning -- Operational guidance for tuning Dispatch performance in production