Skip to content

Caching

Verified by tests

SystemCommandsTests — library CI run #31657041675 (2026-08-13)

Whizbang provides distributed caching capabilities with automatic invalidation support through command-based cache clearing.

Overview

Caching in Whizbang follows these principles:

  • Distributed First: Built for multi-instance deployments
  • Command-Based Invalidation: Cache clearing via messages
  • Coordinated: Cache clears propagate across all instances
  • Type-Safe: Generic cache keys and values

Clear Cache Command

The ClearCacheCommand is a system command (in Whizbang.Core.Commands.System) that enables coordinated cache invalidation across distributed instances. System commands are routed via the whizbang.system.commands namespace, and all services using SharedTopicInboxStrategy automatically subscribe to them.

Usage

Usage

using Whizbang.Core.Commands.System;

// Clear a specific cache key
var command = new ClearCacheCommand(CacheKey: "product:123");

await dispatcher.SendAsync(command);
// All instances receive command and clear their caches

Command Structure

Command Structure

namespace Whizbang.Core.Commands.System;

/// <summary>
/// Command to clear cached data across all services.
/// </summary>
/// <param name="CacheKey">Optional specific cache key to clear. If null, clears all caches.</param>
/// <param name="CacheRegion">Optional cache region/namespace to target.</param>
[PinnedId("db190b57-50ca-4748-9929-0f090dba9e28")]
public record ClearCacheCommand(
    string? CacheKey = null,
    string? CacheRegion = null
) : ICommand;

Updated

The shipped command takes a single optional CacheKey and an optional CacheRegion. There is no Keys list and no Pattern property - to clear multiple keys, send multiple commands or use a region; pattern semantics (e.g. treating a key like product:* as a wildcard) are up to your receptor implementation.

Patterns

Clear a Specific Key

Clear a Specific Key

// Clear one exact key
await dispatcher.SendAsync(new ClearCacheCommand(CacheKey: "user:123"));

Clear by Region

Clear by Region

// Clear all keys in a region
await dispatcher.SendAsync(new ClearCacheCommand(CacheRegion: "ProductCatalog"));

Clear All

Clear All

// Clear entire cache
await dispatcher.SendAsync(new ClearCacheCommand());

Implementing a Cache Receptor

Handle cache clearing in your service. Receptors implementing IReceptor<TMessage, TResponse> are discovered automatically by the source generators:

Implementing a Cache Receptor

using Whizbang.Core;
using Whizbang.Core.Commands.System;

public class CacheClearReceptor : IReceptor<ClearCacheCommand, CacheCleared> {
  private readonly ICacheService _cache;
  private readonly ILogger<CacheClearReceptor> _logger;

  public CacheClearReceptor(ICacheService cache, ILogger<CacheClearReceptor> logger) {
    _cache = cache;
    _logger = logger;
  }

  public async ValueTask<CacheCleared> HandleAsync(
      ClearCacheCommand message,
      CancellationToken ct = default) {

    var clearedKeys = 0;

    if (!string.IsNullOrEmpty(message.CacheKey)) {
      // Clear a specific key
      await _cache.RemoveAsync(message.CacheKey, ct);
      clearedKeys = 1;
    } else if (!string.IsNullOrEmpty(message.CacheRegion)) {
      // Clear region
      clearedKeys = await _cache.ClearRegionAsync(message.CacheRegion, ct);
    } else {
      // Clear all
      await _cache.ClearAsync(ct);
      clearedKeys = -1; // Unknown count
    }

    _logger.LogInformation(
      "Cache cleared: {KeyCount} keys removed",
      clearedKeys == -1 ? "all" : clearedKeys
    );

    return new CacheCleared {
      KeysCleared = clearedKeys,
      ClearedAt = DateTimeOffset.UtcNow
    };
  }
}

public record CacheCleared : IEvent {
  public int KeysCleared { get; init; }
  public DateTimeOffset ClearedAt { get; init; }
}

ICacheService Interface

Whizbang does not ship a cache service abstraction - define one in your application and register your own implementation:

ICacheService Interface

// Application-defined abstraction (not part of Whizbang)
public interface ICacheService {
  // Get/Set
  Task<T?> GetAsync<T>(string key, CancellationToken ct = default);
  Task SetAsync<T>(string key, T value, TimeSpan? expiration = null, CancellationToken ct = default);

  // Remove
  Task RemoveAsync(string key, CancellationToken ct = default);
  Task<int> RemoveByPatternAsync(string pattern, CancellationToken ct = default);

  // Clear
  Task ClearAsync(CancellationToken ct = default);
  Task<int> ClearRegionAsync(string region, CancellationToken ct = default);

  // Exists
  Task<bool> ExistsAsync(string key, CancellationToken ct = default);
}

Distributed Cache Example

Using Redis as distributed cache:

Distributed Cache Example

using StackExchange.Redis;

public class RedisCacheService : ICacheService {
  private readonly IConnectionMultiplexer _redis;
  private readonly ILogger<RedisCacheService> _logger;

  public RedisCacheService(IConnectionMultiplexer redis, ILogger<RedisCacheService> logger) {
    _redis = redis;
    _logger = logger;
  }

  public async Task<T?> GetAsync<T>(string key, CancellationToken ct = default) {
    var db = _redis.GetDatabase();
    var value = await db.StringGetAsync(key);

    if (!value.HasValue) {
      return default;
    }

    return JsonSerializer.Deserialize<T>(value!);
  }

  public async Task SetAsync<T>(
      string key,
      T value,
      TimeSpan? expiration = null,
      CancellationToken ct = default) {

    var db = _redis.GetDatabase();
    var serialized = JsonSerializer.Serialize(value);

    await db.StringSetAsync(key, serialized, expiration);
  }

  public async Task RemoveAsync(string key, CancellationToken ct = default) {
    var db = _redis.GetDatabase();
    await db.KeyDeleteAsync(key);
  }

  public async Task<int> RemoveByPatternAsync(string pattern, CancellationToken ct = default) {
    var server = _redis.GetServer(_redis.GetEndPoints().First());
    var keys = server.Keys(pattern: pattern).ToArray();

    var db = _redis.GetDatabase();
    await db.KeyDeleteAsync(keys);

    return keys.Length;
  }

  public async Task ClearAsync(CancellationToken ct = default) {
    var endpoints = _redis.GetEndPoints();
    foreach (var endpoint in endpoints) {
      var server = _redis.GetServer(endpoint);
      await server.FlushDatabaseAsync();
    }
  }

  public async Task<int> ClearRegionAsync(string region, CancellationToken ct = default) {
    return await RemoveByPatternAsync($"{region}:*", ct);
  }

  public async Task<bool> ExistsAsync(string key, CancellationToken ct = default) {
    var db = _redis.GetDatabase();
    return await db.KeyExistsAsync(key);
  }
}

Registration

Registration

// Program.cs
using StackExchange.Redis;

var builder = WebApplication.CreateBuilder(args);

// Register Redis
var redisConnection = await ConnectionMultiplexer.ConnectAsync(
  builder.Configuration.GetConnectionString("Redis")!
);
builder.Services.AddSingleton<IConnectionMultiplexer>(redisConnection);

// Register cache service
builder.Services.AddSingleton<ICacheService, RedisCacheService>();

// CacheClearReceptor is discovered and registered automatically
// by Whizbang's receptor source generators - no manual registration needed

var app = builder.Build();

Cache Patterns

Write-Through Cache

Update cache when data changes:

Write-Through Cache

public class UpdateProductReceptor : IReceptor<UpdateProduct, (ProductUpdated, ClearCacheCommand)> {
  private readonly IProductRepository _repository;

  public async ValueTask<(ProductUpdated, ClearCacheCommand)> HandleAsync(
      UpdateProduct message,
      CancellationToken ct = default) {

    // Update database
    await _repository.UpdateAsync(message.ProductId, message.Name, message.Price, ct);

    // Return event + cache clear command (auto-cascade)
    return (
      new ProductUpdated {
        ProductId = message.ProductId,
        Name = message.Name,
        Price = message.Price
      },
      new ClearCacheCommand(CacheKey: $"product:{message.ProductId}")
    );
  }
}

Cache-Aside Pattern

Cache-Aside Pattern

public class GetProductReceptor : IReceptor<GetProduct, ProductDto> {
  private readonly ICacheService _cache;
  private readonly IProductLens _lens;

  public async ValueTask<ProductDto> HandleAsync(
      GetProduct query,
      CancellationToken ct = default) {

    var cacheKey = $"product:{query.ProductId}";

    // Try cache first
    var cached = await _cache.GetAsync<ProductDto>(cacheKey, ct);
    if (cached != null) {
      return cached;
    }

    // Cache miss - query database
    var product = await _lens.GetProductAsync(query.ProductId, ct);

    // Store in cache
    await _cache.SetAsync(cacheKey, product, TimeSpan.FromMinutes(15), ct);

    return product;
  }
}

Best Practices

DO

  • Use distributed cache for multi-instance deployments
  • Set expiration times to prevent stale data
  • Clear cache proactively when data changes
  • Use cache keys consistently (e.g., {entity}:{id} pattern)
  • Handle cache misses gracefully with fallback to database

DON'T

  • Don't cache forever without expiration
  • Don't ignore cache clear failures (log and alert)
  • Don't cache sensitive data without encryption
  • Don't over-cache (cache only frequently accessed data)
  • Don't rely on cache alone (always have database fallback)

See Also


Version 1.0.0 - Foundation Release