Skip to content

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.

  • .NET 10.0+
  • Pragmatic.Caching package
  • Pragmatic.SourceGenerator analyzer reference
  • Microsoft.Extensions.Caching.Hybrid registered in DI
// Program.cs or Startup
builder.Services.AddHybridCache(); // Required: Microsoft HybridCache
builder.Services.AddPragmaticCaching(); // Registers ICacheStack -> HybridCacheStack

For distributed caching (L2), also register a distributed cache backend:

// Redis example
builder.Services.AddStackExchangeRedisCache(opts =>
{
opts.Configuration = "localhost:6379";
});
builder.Services.AddHybridCache();
builder.Services.AddPragmaticCaching();

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() returns CacheEntryOptions with 5-minute duration and ["products"] tags

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.

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")

When you need cache isolation between subsystems (e.g., different TTLs, key prefixes, or future separate backends), use category routing.

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:).

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

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

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

Tags allow invalidating groups of related cache entries without tracking individual keys.

[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"]

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

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

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).