Pragmatic.Caching
Source-generated caching with typed keys, tag-based invalidation, category routing, and HybridCache integration for .NET 10.
The Problem
Section titled “The Problem”Caching in .NET means scattered magic strings, manual serialization, and ad-hoc invalidation:
public class ProductService(IDistributedCache cache, IProductRepository repo){ public async Task<ProductDto?> GetByIdAsync(int id, CancellationToken ct) { var key = $"product:{id}"; // Magic string — rename the entity and this silently breaks var cached = await cache.GetStringAsync(key, ct); if (cached is not null) return JsonSerializer.Deserialize<ProductDto>(cached);
var product = await repo.GetByIdAsync(id, ct); if (product is null) return null;
var dto = ProductDto.FromEntity(product); await cache.SetStringAsync(key, JsonSerializer.Serialize(dto), new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5) }, ct); return dto; }}Every cached query requires hand-rolled key construction, manual serialization, hardcoded TTLs, and a separate invalidation handler that must know every key pattern. Miss one key in the invalidation handler and you serve stale data. Share a cache backend between subsystems (permissions, rate limiting, output cache) and you get key collisions and invalidation noise.
The Solution
Section titled “The Solution”Declare what to cache and when to invalidate. The source generator produces the key generation, options construction, and invalidation methods at compile time:
[Cacheable(Duration = "5m", Tags = ["products", "product:{ProductId}"])]public partial class GetProductById{ public required int ProductId { get; init; }}
[InvalidatesCache("products", "product:{ProductId}")]public partial class ProductUpdated{ public int ProductId { get; init; }}The SG generates GetCacheKey() (returns "GetProductById:ProductId=42"), GetCacheOptions() (duration, tags, priority), and InvalidateAsync() (expands placeholders, calls tag invalidation). No magic strings. No manual serialization. No reflection at runtime.
For subsystem isolation, category routing gives each subsystem its own ICacheStack with key prefix, independent default TTL, and isolated tag namespace. The core abstraction is ICacheStack (in Pragmatic.Abstractions), backed by HybridCacheStack (L1 memory + L2 distributed via Microsoft.Extensions.Caching.Hybrid).
Installation
Section titled “Installation”dotnet add package Pragmatic.CachingAdd the Pragmatic source generator as a project reference:
<ProjectReference Include="..\Pragmatic.SourceGenerator\src\Pragmatic.SourceGenerator\Pragmatic.SourceGenerator.csproj" OutputItemType="Analyzer" ReferenceOutputAssembly="false" />Quick Start
Section titled “Quick Start”Basic (No Categories)
Section titled “Basic (No Categories)”// Register HybridCache (required) + Pragmatic.Cachingbuilder.Services.AddHybridCache();builder.Services.AddPragmaticCaching();With Category Routing
Section titled “With Category Routing”builder.Services.AddHybridCache();builder.Services.AddPragmaticCaching(cache =>{ cache.WithDefaultOptions(o => o.DefaultDuration = TimeSpan.FromMinutes(10)); cache.ForCategory<CacheCategories.OutputCache>(o => o.KeyPrefix = "oc:"); cache.ForCategory<CacheCategories.Permissions>(o => { o.KeyPrefix = "perms:"; o.DefaultDuration = TimeSpan.FromMinutes(5); });});Mark a Query as Cacheable
Section titled “Mark a Query as Cacheable”[Cacheable(Duration = "5m", Tags = ["users", "users:{UserId}"])]public partial class GetUserById{ [CacheKey] public Guid UserId { get; set; }}The source generator produces:
GetCacheKey()— returns"GetUserById:UserId={value}"GetCacheOptions()— returnsCacheEntryOptionswith duration, tags, priorityCacheCategory— returns the category type ifCategoryis specified
Use the Cache
Section titled “Use the Cache”var query = new GetUserById { UserId = userId };var key = query.GetCacheKey();var options = query.GetCacheOptions();
var user = await cacheStack.GetOrSetAsync(key, async ct => await repository.GetByIdAsync(query.UserId, ct), options, ct);Invalidate on Events
Section titled “Invalidate on Events”[InvalidatesCache(Tags = ["users:{UserId}"])]public partial class UserUpdatedEvent{ public Guid UserId { get; set; }}The source generator produces an InvalidateAsync() method that expands tag placeholders and calls InvalidateByTagsAsync().
Attributes
Section titled “Attributes”[Cacheable]
Section titled “[Cacheable]”Marks a type for cache key and options generation. Applied to DomainAction queries or standalone types.
| Property | Type | Default | Description |
|---|---|---|---|
Duration | string | "5m" | Cache duration. Formats: "5m", "1h", "1d", or TimeSpan string |
Tags | string[]? | null | Tags for group invalidation. Supports {Property} placeholders |
Sliding | bool | false | When true, uses sliding expiration (resets on access) |
Priority | CachePriority | Normal | Eviction priority: Low, Normal, High, NeverRemove |
Category | Type? | null | Cache category marker type for routing to a specific ICacheStack |
Duration formats:
"30s"— 30 seconds"5m"— 5 minutes"1h"— 1 hour"1d"— 1 day"00:15:00"— TimeSpan format (15 minutes)
Category usage:
[Cacheable(Duration = "5m", Category = typeof(CacheCategories.Permissions))]public partial class GetUserPermissions{ [CacheKey] public required Guid UserId { get; init; }}When Category is specified, the generated CacheCategory property returns the marker type. This allows the runtime to resolve the category-specific ICacheStack via CacheStackProvider.ForCategory<T>().
[CacheKey]
Section titled “[CacheKey]”Controls how a property contributes to cache key generation. Applied to properties within a [Cacheable] type.
| Property | Type | Default | Description |
|---|---|---|---|
Name | string? | null | Custom name in the key segment. Default: property name |
Exclude | bool | false | When true, excludes this property from the key |
Order | int | int.MaxValue | Key segment order. Lower values appear first |
Default behavior: All public properties are included in the cache key, ordered by declaration. Use [CacheKey] only when you need to customize.
[Cacheable(Duration = "5m")]public partial class GetUser{ [CacheKey(Order = 0)] public required int TenantId { get; init; }
[CacheKey(Name = "id")] public required int UserId { get; init; }
[CacheKey(Exclude = true)] public bool IncludeDeleted { get; init; }}// Generated key: "GetUser:TenantId={TenantId}:id={UserId}"[InvalidatesCache]
Section titled “[InvalidatesCache]”Marks a domain event or type for cache invalidation generation.
| Property | Type | Default | Description |
|---|---|---|---|
Tags | string[] | [] | Tags to invalidate. Supports {Property} placeholders |
Keys | string[]? | null | Explicit cache keys to remove |
Category | Type? | null | Target category. When null, broadcast to ALL categories |
Broadcast vs targeted invalidation:
When Category is null (default), InvalidateAsync broadcasts to all registered category backends. When a specific category is set, invalidation targets only that category’s ICacheStack.
// Broadcast: invalidates across all categories[InvalidatesCache("users", "profiles:{UserId}")]public class UserProfileChanged : IDomainEvent{ public int UserId { get; init; }}
// Targeted: only invalidates within the Permissions category[InvalidatesCache("user:{UserId}", Category = typeof(CacheCategories.Permissions))]public class UserRolesChanged : IDomainEvent{ public Guid UserId { get; init; }}Category Routing
Section titled “Category Routing”Categories allow different subsystems to use isolated cache namespaces with independent configuration. Each category is a marker type used as a generic type parameter.
Predefined Categories (in Pragmatic.Abstractions)
Section titled “Predefined Categories (in Pragmatic.Abstractions)”| Category | Marker Type | Used By | Purpose |
|---|---|---|---|
| Default | CacheCategories.Default | General business queries | Default when no category is specified |
| OutputCache | CacheCategories.OutputCache | PragmaticOutputCacheStore | HTTP response caching |
| RateLimiting | CacheCategories.RateLimiting | PragmaticDistributedRateLimiter | Cross-instance rate limit counters |
| Permissions | CacheCategories.Permissions | CachedPermissionResolver | Cross-request permission set caching |
| Configuration | CacheCategories.Configuration | ConfigurationResolver | Remote configuration value caching |
Custom Categories
Section titled “Custom Categories”Define application-specific categories as sealed marker classes:
public static class AppCacheCategories{ public sealed class Analytics; public sealed class UserSessions;}CachingBuilder API
Section titled “CachingBuilder API”Configure categories via AddPragmaticCaching(Action<CachingBuilder>):
| Method | Description |
|---|---|
WithDefaultOptions(Action<CachingOptions>) | Configures global defaults (applied when no category-specific config exists) |
ForCategory<T>(Action<CategoryCacheOptions>) | Registers a category with specific options |
CategoryCacheOptions properties:
| Property | Type | Default | Description |
|---|---|---|---|
KeyPrefix | string | Auto-generated from type name | Key prefix for category isolation |
DefaultDuration | TimeSpan? | null (falls back to global) | Default TTL for entries without explicit duration |
If KeyPrefix is not set, it auto-generates from the category type name (e.g., OutputCache becomes outputcache:).
CacheStackProvider Resolution Flow
Section titled “CacheStackProvider Resolution Flow”CacheStackProvider.ForCategory<TCategory>(IServiceProvider) resolves the correct ICacheStack:
- Try to resolve a keyed
ICacheStackwith keytypeof(TCategory).FullName - If not found, fall back to the default (unkeyed)
ICacheStack
// Resolve category-specific cachevar permCache = CacheStackProvider.ForCategory<CacheCategories.Permissions>(sp);
// In bridges (Output Cache, Rate Limiter), this happens automaticallySG Auto-Detection
Section titled “SG Auto-Detection”The source generator detects category usage from [Cacheable(Category = typeof(...))] and [InvalidatesCache(Category = typeof(...))] across referenced assemblies. It auto-generates ForCategory<T>() registrations in the host’s RegisterAllPragmaticServices() method.
ICacheable / ICacheInvalidator Interfaces
Section titled “ICacheable / ICacheInvalidator Interfaces”ICacheable
Section titled “ICacheable”Generated on types marked with [Cacheable]:
public interface ICacheable{ string GetCacheKey(); CacheEntryOptions GetCacheOptions(); Type? CacheCategory => null; // Default interface method; overridden when Category is specified}ICacheInvalidator
Section titled “ICacheInvalidator”Generated on types marked with [InvalidatesCache]:
public interface ICacheInvalidator{ ValueTask InvalidateAsync(ICacheStack cache, CancellationToken ct = default); Type? InvalidationCategory => null; // null = broadcast to all categories}Runtime API
Section titled “Runtime API”ICacheStack (in Pragmatic.Abstractions)
Section titled “ICacheStack (in Pragmatic.Abstractions)”Unified caching abstraction with stampede protection and tag-based invalidation:
public interface ICacheStack{ ValueTask<T> GetOrSetAsync<T>(string key, Func<CancellationToken, ValueTask<T>> factory, CacheEntryOptions? options = null, CancellationToken ct = default);
ValueTask<T?> GetAsync<T>(string key, CancellationToken ct = default);
ValueTask<(bool Found, T? Value)> TryGetAsync<T>(string key, CancellationToken ct = default);
ValueTask SetAsync<T>(string key, T value, CacheEntryOptions? options = null, CancellationToken ct = default);
ValueTask RemoveAsync(string key, CancellationToken ct = default);
ValueTask InvalidateByTagAsync(string tag, CancellationToken ct = default); ValueTask InvalidateByTagsAsync(IEnumerable<string> tags, CancellationToken ct = default);}CacheEntryOptions (in Pragmatic.Abstractions)
Section titled “CacheEntryOptions (in Pragmatic.Abstractions)”public sealed class CacheEntryOptions{ public TimeSpan? Duration { get; init; } public TimeSpan? SlidingDuration { get; init; } public ImmutableArray<string> Tags { get; init; } public CachePriority Priority { get; init; }
public static CacheEntryOptions Default { get; } // 5 min duration public static CacheEntryOptions WithDuration(TimeSpan d); public static CacheEntryOptions WithSliding(TimeSpan d);}CachePriority (in Pragmatic.Abstractions)
Section titled “CachePriority (in Pragmatic.Abstractions)”public enum CachePriority{ Low = 0, // First to be evicted Normal = 1, // Default High = 2, // Less likely to be evicted NeverRemove = 3 // Never auto-evicted (use sparingly)}HybridCacheStack
Section titled “HybridCacheStack”Default ICacheStack implementation. Wraps Microsoft.Extensions.Caching.Hybrid.HybridCache for L1 (in-memory) + L2 (distributed) caching with stampede protection. Multiple concurrent requests for the same uncached key result in only one factory call.
PrefixedCacheStack (internal)
Section titled “PrefixedCacheStack (internal)”Wraps an ICacheStack adding a key prefix to all operations. Created automatically by ForCategory<T>(). Prefixes both keys and tags for full category isolation.
CacheStackProvider
Section titled “CacheStackProvider”Static helper for resolving category-specific ICacheStack instances:
public static class CacheStackProvider{ // Resolve by generic type parameter public static ICacheStack ForCategory<TCategory>(IServiceProvider sp);
// Resolve by runtime Type public static ICacheStack ForCategory(IServiceProvider sp, Type categoryType);}CachingOptions
Section titled “CachingOptions”Global configuration for Pragmatic.Caching:
builder.Services.AddPragmaticCaching(opts =>{ opts.DefaultDuration = TimeSpan.FromMinutes(5); // Default when not specified opts.EnableQueryCaching = true; // Auto-cache [Cacheable] types opts.EnableEventInvalidation = true; // Auto-invalidate on [InvalidatesCache] opts.KeyPrefix = "myapp:"; // Prefix for multi-tenant isolation});Observability
Section titled “Observability”HybridCacheStack provides built-in OpenTelemetry integration:
Activities (distributed tracing):
Cache.GetOrSet,Cache.TryGet,Cache.Set,Cache.Remove,Cache.InvalidateByTag,Cache.InvalidateByTags- Tags:
pragmatic.cache.key,pragmatic.cache.operation,cache.hit
Metrics (via System.Diagnostics.Metrics):
pragmatic.cache.hits— counter of cache hitspragmatic.cache.misses— counter of cache missespragmatic.cache.sets— counter of set operationspragmatic.cache.invalidations— counter of invalidations
Structured logging (via [LoggerMessage]):
- Debug: get-or-set, hit, miss, set, remove
- Information: tag invalidation
- Warning: tag invalidation failure
Source name: Pragmatic.Caching.
Cross-Module Integration
Section titled “Cross-Module Integration”| With Module | Integration |
|---|---|
| Pragmatic.Endpoints | PragmaticOutputCacheStore bridges ASP.NET Output Cache to ICacheStack via CacheCategories.OutputCache. PragmaticDistributedRateLimiter uses CacheCategories.RateLimiting for cross-instance counters. |
| Pragmatic.Authorization | CachedPermissionResolver uses ICacheStack (optionally injected) for cross-request permission caching. Uses CacheCategories.Permissions for isolation. |
| Pragmatic.Configuration | Remote configuration values cached via ICacheStack using CacheCategories.Configuration. Replaces the former IConfigurationCache interface. |
| Pragmatic.Actions | [Cacheable] on DomainAction enables invoker-level result caching |
| Pragmatic.Persistence | Query results cached with entity-tag invalidation on mutations |
| Pragmatic.Events | Domain events with [InvalidatesCache] clear stale entries |
Source Generator Diagnostics
Section titled “Source Generator Diagnostics”| ID | Severity | Description |
|---|---|---|
PRAG1700 | Error | [Cacheable] type must be partial |
PRAG1701 | Error | Invalid cache duration format |
PRAG1702 | Error | No cache key properties found |
PRAG1703 | Error | Tag/key placeholder references non-existent property |
PRAG1750 | Warning | Duplicate [CacheKey(Order)] values |
PRAG1751 | Warning | All properties excluded from cache key |
All Public Types
Section titled “All Public Types”Pragmatic.Abstractions (Caching namespace)
Section titled “Pragmatic.Abstractions (Caching namespace)”| Type | Kind | Purpose |
|---|---|---|
ICacheStack | Interface | Unified caching abstraction (get/set/remove/invalidate) |
CacheEntryOptions | Class | Entry options: duration, sliding, tags, priority |
CachePriority | Enum | Eviction priority (Low, Normal, High, NeverRemove) |
CacheCategories | Static class | Predefined category marker types |
CacheCategories.Default | Class | Default cache category |
CacheCategories.OutputCache | Class | HTTP response caching category |
CacheCategories.RateLimiting | Class | Rate limit counter category |
CacheCategories.Permissions | Class | Permission caching category |
CacheCategories.Configuration | Class | Configuration value caching category |
Pragmatic.Caching
Section titled “Pragmatic.Caching”| Type | Kind | Purpose |
|---|---|---|
HybridCacheStack | Class | Default ICacheStack (L1 memory + L2 distributed via HybridCache) |
CachingBuilder | Class | Fluent builder for per-category routing configuration |
CacheStackProvider | Static class | Resolves category-specific ICacheStack (keyed, fallback to default) |
CategoryCacheOptions | Class | Per-category configuration (KeyPrefix, DefaultDuration) |
CachingOptions | Class | Global configuration (DefaultDuration, KeyPrefix, etc.) |
ICacheable | Interface | Generated on [Cacheable] types: GetCacheKey, GetCacheOptions, CacheCategory |
ICacheInvalidator | Interface | Generated on [InvalidatesCache] types: InvalidateAsync, InvalidationCategory |
CacheableAttribute | Attribute | Marks a type as cacheable (Duration, Tags, Sliding, Priority, Category) |
InvalidatesCacheAttribute | Attribute | Marks a type for invalidation (Tags, Keys, Category) |
CacheKeyAttribute | Attribute | Customizes property contribution to cache key (Name, Exclude, Order) |
CachingDiagnostics | Static class | OTel ActivitySource and Meter instruments |
Samples
Section titled “Samples”See samples/Pragmatic.Caching.Samples/ for 5 runnable scenarios: cache key generation (basic, multi-property, custom names, static helpers), cache options (duration, sliding, tags with placeholders), invalidation patterns (event-driven, convention-based), real ICacheStack usage (GetOrSetAsync hit/miss, RemoveAsync), and DI setup patterns.
Requirements
Section titled “Requirements”- .NET 10.0+
Microsoft.Extensions.Caching.HybridPragmatic.SourceGeneratoranalyzer
License
Section titled “License”Part of the Pragmatic.Design ecosystem.