Pragmatic.Caching -- Getting Started
This guide walks through adding caching to a query or DomainAction, customizing cache keys, setting up tag-based invalidation, and configuring category routing.
Prerequisites
Section titled “Prerequisites”- .NET 10.0+
Pragmatic.CachingpackagePragmatic.SourceGeneratoranalyzer referenceMicrosoft.Extensions.Caching.Hybridregistered in DI
Step 1: Register Services
Section titled “Step 1: Register Services”// Program.cs or Startupbuilder.Services.AddHybridCache(); // Required: Microsoft HybridCachebuilder.Services.AddPragmaticCaching(); // Registers ICacheStack -> HybridCacheStackFor distributed caching (L2), also register a distributed cache backend:
// Redis examplebuilder.Services.AddStackExchangeRedisCache(opts =>{ opts.Configuration = "localhost:6379";});builder.Services.AddHybridCache();builder.Services.AddPragmaticCaching();Step 2: Make a Query Cacheable
Section titled “Step 2: Make a Query Cacheable”Add [Cacheable] to your query class. The class must be partial:
using Pragmatic.Caching.Attributes;
[Cacheable(Duration = "5m", Tags = ["products"])]public partial class GetProductById{ public required Guid ProductId { get; init; }}The source generator produces a partial class implementing ICacheable:
GetCacheKey()returns"GetProductById:ProductId={value}"GetCacheOptions()returnsCacheEntryOptionswith 5-minute duration and["products"]tags
Step 3: Use the Cache in Your Code
Section titled “Step 3: Use the Cache in Your Code”Inject ICacheStack and use the generated methods:
public class ProductQueryHandler(ICacheStack cache, IProductRepository repo){ public async Task<ProductDto?> HandleAsync(GetProductById query, CancellationToken ct) { var key = query.GetCacheKey(); var options = query.GetCacheOptions();
return await cache.GetOrSetAsync(key, async token => await repo.GetByIdAsync(query.ProductId, token), options, ct); }}GetOrSetAsync includes stampede protection: if 10 concurrent requests arrive for the same uncached key, only one factory call executes. The other 9 wait for its result.
Step 4: Invalidate on Events
Section titled “Step 4: Invalidate on Events”Mark domain events with [InvalidatesCache] to auto-generate invalidation logic:
[InvalidatesCache("products", "tenant:{TenantId}")]public partial class ProductCreated{ public Guid ProductId { get; init; } public int TenantId { get; init; }}The generator produces an ICacheInvalidator implementation:
// Generated: calls cache.InvalidateByTagsAsync(["products", "tenant:42"])await invalidator.InvalidateAsync(cacheStack, ct);Convention-based invalidation (no explicit tags) derives the tag from the event name:
[InvalidatesCache]public class ProductUpdated { }// Invalidates the "products" tag (derived from "ProductUpdated")Adding Categories
Section titled “Adding Categories”When you need cache isolation between subsystems (e.g., different TTLs, key prefixes, or future separate backends), use category routing.
Step 5: Register with Categories
Section titled “Step 5: Register with Categories”Switch from the simple AddPragmaticCaching() to the builder overload:
builder.Services.AddHybridCache();builder.Services.AddPragmaticCaching(cache =>{ cache.WithDefaultOptions(o => o.DefaultDuration = TimeSpan.FromMinutes(10)); cache.ForCategory<CacheCategories.Permissions>(o => { o.KeyPrefix = "perms:"; o.DefaultDuration = TimeSpan.FromMinutes(5); });});Each ForCategory<T>() call registers a keyed ICacheStack backed by PrefixedCacheStack, which wraps the default ICacheStack with a key prefix and optional default duration.
If KeyPrefix is not explicitly set, it auto-generates from the category type name (e.g., Permissions becomes permissions:).
Step 6: Use Category on Cacheable Types
Section titled “Step 6: Use Category on Cacheable Types”[Cacheable(Duration = "5m", Category = typeof(CacheCategories.Permissions))]public partial class GetUserPermissions{ [CacheKey] public required Guid UserId { get; init; }}The generated CacheCategory property returns typeof(CacheCategories.Permissions). At runtime, CacheStackProvider.ForCategory<CacheCategories.Permissions>(sp) resolves the category-specific ICacheStack.
Step 7: Targeted vs Broadcast Invalidation
Section titled “Step 7: Targeted vs Broadcast Invalidation”By default, [InvalidatesCache] broadcasts to all categories. Set Category to target a specific one:
// Broadcast: invalidates across ALL categories[InvalidatesCache("users:{UserId}")]public class UserUpdated : IDomainEvent { public Guid UserId { get; init; } }
// Targeted: only invalidates the Permissions category[InvalidatesCache("user:{UserId}", Category = typeof(CacheCategories.Permissions))]public class UserRolesChanged : IDomainEvent { public Guid UserId { get; init; } }Customizing Cache Keys
Section titled “Customizing Cache Keys”Controlling Key Order
Section titled “Controlling Key Order”Use [CacheKey(Order)] to control the order of segments in the key:
[Cacheable(Duration = "10m")]public partial class SearchProducts{ [CacheKey(Order = 0)] public required int TenantId { get; init; }
[CacheKey(Order = 1)] public required string Category { get; init; }
public int Page { get; init; } = 1; public int PageSize { get; init; } = 20;}// Key: "SearchProducts:TenantId={t}:Category={c}:Page={p}:PageSize={ps}"Excluding Properties
Section titled “Excluding Properties”Some properties should not affect the cache key:
[Cacheable(Duration = "5m")]public partial class GetProducts{ public required string Category { get; init; }
[CacheKey(Exclude = true)] public bool IncludeDebugInfo { get; init; } // Not part of the key}Custom Key Names
Section titled “Custom Key Names”[Cacheable(Duration = "5m")]public partial class GetUser{ [CacheKey(Name = "id")] public required Guid UserId { get; init; }}// Key: "GetUser:id={value}" instead of "GetUser:UserId={value}"Tag-Based Invalidation
Section titled “Tag-Based Invalidation”Tags allow invalidating groups of related cache entries without tracking individual keys.
Declaring Tags
Section titled “Declaring Tags”[Cacheable(Duration = "10m", Tags = ["products", "tenant:{TenantId}"])]public partial class GetProductsByTenant{ public required int TenantId { get; init; }}Tag placeholders ({TenantId}) are expanded at runtime using the property value:
- For
TenantId = 42, the tags become["products", "tenant:42"]
Manual Cache Operations
Section titled “Manual Cache Operations”For scenarios beyond the attribute-based pattern:
public class CartService(ICacheStack cache){ // Set explicitly public async Task SetCartAsync(Guid userId, Cart cart, CancellationToken ct) { var key = $"cart:{userId}"; var options = new CacheEntryOptions { Duration = TimeSpan.FromMinutes(30), Tags = [$"cart", $"user:{userId}"] }; await cache.SetAsync(key, cart, options, ct); }
// Get with miss detection public async Task<Cart?> GetCartAsync(Guid userId, CancellationToken ct) { var (found, cart) = await cache.TryGetAsync<Cart>($"cart:{userId}", ct); return found ? cart : null; }
// Remove specific key public async Task ClearCartAsync(Guid userId, CancellationToken ct) { await cache.RemoveAsync($"cart:{userId}", ct); }
// Invalidate all entries tagged with "cart" public async Task ClearAllCartsAsync(CancellationToken ct) { await cache.InvalidateByTagAsync("cart", ct); }}Multi-Tenant Isolation
Section titled “Multi-Tenant Isolation”Use the KeyPrefix option to namespace cache keys by tenant or application:
builder.Services.AddPragmaticCaching(opts =>{ opts.KeyPrefix = "tenant42:";});Or use tag-based isolation with {TenantId} placeholders in tags:
[Cacheable(Duration = "5m", Tags = ["tenant:{TenantId}:products"])]public partial class GetProducts{ public required int TenantId { get; init; }}Eviction Priorities
Section titled “Eviction Priorities”Control which entries are evicted first under memory pressure:
[Cacheable(Duration = "1h", Priority = CachePriority.High)]public partial class GetSystemConfig { }
[Cacheable(Duration = "5m", Priority = CachePriority.Low)]public partial class GetRecentSearches{ public required Guid UserId { get; init; }}Priority levels: Low (evict first), Normal (default), High (keep longer), NeverRemove (only expires by duration).