Skip to main content

Overview

The Caching building block provides a hybrid L1/L2 cache implementation with Redis support. It combines in-memory (L1) and distributed (L2) caching for optimal performance.
The hybrid cache uses in-memory cache (L1) for hot data and Redis (L2) for shared cache across instances.

Key Components

ICacheService

Main abstraction for caching operations:
ICacheService.cs
namespace FSH.Framework.Caching;

public interface ICacheService
{
    // Async operations
    Task<T?> GetItemAsync<T>(string key, CancellationToken ct = default);
    Task SetItemAsync<T>(string key, T value, TimeSpan? sliding = default, CancellationToken ct = default);
    Task RemoveItemAsync(string key, CancellationToken ct = default);
    Task RefreshItemAsync(string key, CancellationToken ct = default);

    // Sync operations
    T? GetItem<T>(string key);
    void SetItem<T>(string key, T value, TimeSpan? sliding = default);
    void RemoveItem(string key);
    void RefreshItem(string key);
}

CachingOptions

Configuration for caching behavior:
CachingOptions.cs
namespace FSH.Framework.Caching;

public sealed class CachingOptions
{
    /// <summary>Redis connection string. If empty, falls back to in-memory.</summary>
    public string Redis { get; set; } = string.Empty;

    /// <summary>
    /// Enable SSL for Redis connection. If null, uses connection string default.
    /// Set to true when using Aspire or cloud Redis that requires SSL.
    /// </summary>
    public bool? EnableSsl { get; set; }

    /// <summary>Default sliding expiration if caller doesn't specify.</summary>
    public TimeSpan? DefaultSlidingExpiration { get; set; } = TimeSpan.FromMinutes(5);

    /// <summary>Default absolute expiration (cap).</summary>
    public TimeSpan? DefaultAbsoluteExpiration { get; set; } = TimeSpan.FromMinutes(15);

    /// <summary>Optional prefix (env/tenant/app) applied to all keys.</summary>
    public string? KeyPrefix { get; set; } = "fsh_";
}

Registration

using FSH.Framework.Caching;

builder.Services.AddHeroCaching(builder.Configuration);

// Or via platform registration
builder.AddHeroPlatform(options =>
{
    options.EnableCaching = true; // Enables caching
});

Usage Examples

Basic Caching

GetProductHandler.cs
using FSH.Framework.Caching;

public sealed class GetProductHandler : IQueryHandler<GetProductQuery, ProductDto?>
{
    private readonly CatalogDbContext _db;
    private readonly ICacheService _cache;

    public GetProductHandler(CatalogDbContext db, ICacheService cache)
    {
        _db = db;
        _cache = cache;
    }

    public async ValueTask<ProductDto?> Handle(GetProductQuery query, CancellationToken ct)
    {
        var cacheKey = $"product:{query.Id}";

        // Try to get from cache
        var cached = await _cache.GetItemAsync<ProductDto>(cacheKey, ct);
        if (cached is not null)
        {
            return cached;
        }

        // Load from database
        var product = await _db.Products
            .Where(p => p.Id == query.Id)
            .Select(p => new ProductDto
            {
                Id = p.Id,
                Name = p.Name,
                Price = p.Price
            })
            .FirstOrDefaultAsync(ct);

        // Cache for 5 minutes (sliding)
        if (product is not null)
        {
            await _cache.SetItemAsync(cacheKey, product, TimeSpan.FromMinutes(5), ct);
        }

        return product;
    }
}

Cache-Aside Pattern

ProductService.cs
using FSH.Framework.Caching;

public sealed class ProductService
{
    private readonly CatalogDbContext _db;
    private readonly ICacheService _cache;

    public async Task<List<ProductDto>> GetFeaturedProductsAsync(CancellationToken ct)
    {
        const string cacheKey = "products:featured";

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

        // Load from database
        var products = await _db.Products
            .Where(p => p.IsFeatured)
            .OrderByDescending(p => p.CreatedOnUtc)
            .Take(10)
            .Select(p => new ProductDto { /* ... */ })
            .ToListAsync(ct);

        // Cache for 10 minutes
        await _cache.SetItemAsync(cacheKey, products, TimeSpan.FromMinutes(10), ct);

        return products;
    }
}

Cache Invalidation

UpdateProductHandler.cs
using FSH.Framework.Caching;

public sealed class UpdateProductHandler : ICommandHandler<UpdateProductCommand>
{
    private readonly CatalogDbContext _db;
    private readonly ICacheService _cache;

    public async ValueTask<Unit> Handle(UpdateProductCommand cmd, CancellationToken ct)
    {
        var product = await _db.Products.FindAsync([cmd.Id], ct)
            ?? throw new NotFoundException($"Product {cmd.Id} not found.");

        product.UpdateName(cmd.Name);
        product.UpdatePrice(cmd.Price);

        await _db.SaveChangesAsync(ct);

        // Invalidate related caches
        await _cache.RemoveItemAsync($"product:{cmd.Id}", ct);
        await _cache.RemoveItemAsync("products:featured", ct);
        await _cache.RemoveItemAsync($"products:category:{product.CategoryId}", ct);

        return Unit.Value;
    }
}

Tenant-Scoped Cache Keys

GetTenantStatsHandler.cs
using FSH.Framework.Caching;
using FSH.Framework.Core.Context;

public sealed class GetTenantStatsHandler : IQueryHandler<GetTenantStatsQuery, TenantStatsDto>
{
    private readonly ICurrentUser _currentUser;
    private readonly ICacheService _cache;
    private readonly AppDbContext _db;

    public async ValueTask<TenantStatsDto> Handle(GetTenantStatsQuery query, CancellationToken ct)
    {
        var tenantId = _currentUser.GetTenantId() ?? "default";
        var cacheKey = $"tenant:{tenantId}:stats";

        var cached = await _cache.GetItemAsync<TenantStatsDto>(cacheKey, ct);
        if (cached is not null) return cached;

        var stats = await CalculateStatsAsync(tenantId, ct);

        await _cache.SetItemAsync(cacheKey, stats, TimeSpan.FromMinutes(15), ct);
        return stats;
    }
}

Synchronous Operations

Use synchronous methods sparingly. Async methods are preferred for scalability.
CacheHelper.cs
using FSH.Framework.Caching;

public sealed class CacheHelper
{
    private readonly ICacheService _cache;

    public string GetOrCreateToken(string userId)
    {
        var cacheKey = $"token:{userId}";

        // Try to get from cache
        var token = _cache.GetItem<string>(cacheKey);
        if (token is not null)
        {
            return token;
        }

        // Generate new token
        token = Guid.NewGuid().ToString("N");

        // Cache for 1 hour
        _cache.SetItem(cacheKey, token, TimeSpan.FromHours(1));

        return token;
    }
}

Cache Strategies

1. Cache-Aside (Lazy Loading)

Most common pattern — application manages cache:
1

Check Cache

Try to read from cache first.
2

Load from Source

If cache miss, load from database.
3

Populate Cache

Store the loaded data in cache for future requests.

2. Write-Through

Update cache when writing to database:
await _db.SaveChangesAsync(ct);
await _cache.SetItemAsync(cacheKey, updatedData, ct);

3. Write-Behind (Invalidation)

Invalidate cache when writing to database:
await _db.SaveChangesAsync(ct);
await _cache.RemoveItemAsync(cacheKey, ct);

Best Practices

1

Use Descriptive Keys

Use namespaced keys: product:{id}, tenant:{tenantId}:orders.
2

Set Appropriate TTLs

Use sliding expiration for frequently accessed data, absolute expiration for time-sensitive data.
3

Invalidate on Write

Always invalidate or update cache when modifying data.
4

Handle Cache Misses

Always have fallback logic when cache is unavailable.
5

Monitor Cache Hit Rates

Track cache effectiveness using telemetry.

Hybrid Cache Architecture

Request

L1 (Memory Cache) — Fast, per-instance
  ↓ (miss)
L2 (Redis) — Shared, distributed
  ↓ (miss)
Database — Source of truth

L1 Cache (In-Memory)

  • Scope: Single application instance
  • Speed: Fastest (nanoseconds)
  • Use Case: Hot data, session state

L2 Cache (Redis)

  • Scope: Shared across all instances
  • Speed: Fast (milliseconds)
  • Use Case: Distributed cache, session sharing

Configuration Options

Local Development (In-Memory)

appsettings.Development.json
{
  "CachingOptions": {
    "Redis": "",  // Empty = fallback to in-memory
    "DefaultSlidingExpiration": "00:05:00"
  }
}

Docker Compose (Redis)

docker-compose.yml
services:
  redis:
    image: redis:7-alpine
    ports:
      - "6379:6379"
appsettings.json
{
  "CachingOptions": {
    "Redis": "localhost:6379"
  }
}

Aspire (Redis with SSL)

AppHost/Program.cs
var redis = builder.AddRedis("cache")
    .WithRedisCommander();

var api = builder.AddProject<Projects.Api>("api")
    .WithReference(redis);
appsettings.json
{
  "CachingOptions": {
    "EnableSsl": true  // Aspire Redis requires SSL
  }
}

Troubleshooting

Redis Connection Failed

If Redis is unavailable, the system falls back to in-memory cache automatically.
# Test Redis connection
redis-cli -h localhost -p 6379 ping

# Check Redis logs
docker logs redis

Cache Not Working

// Verify cache is registered
var cache = app.Services.GetRequiredService<ICacheService>();

// Test set/get
await cache.SetItemAsync("test", "value", ct);
var value = await cache.GetItemAsync<string>("test", ct);

Package Reference

YourModule.csproj
<ItemGroup>
  <ProjectReference Include="..\..\BuildingBlocks\Caching\FSH.Framework.Caching.csproj" />
</ItemGroup>

Jobs Building Block

Background jobs for cache warming

Web Building Block

Response caching and output caching

Redis Documentation

Official Redis documentation

ASP.NET Caching

Microsoft caching best practices

Build docs developers (and LLMs) love