Skip to content

Pragmatic.Caching

Source-generated caching with typed keys, tag-based invalidation, category routing, and HybridCache integration for .NET 10.

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.

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

Terminal window
dotnet add package Pragmatic.Caching

Add the Pragmatic source generator as a project reference:

<ProjectReference Include="..\Pragmatic.SourceGenerator\src\Pragmatic.SourceGenerator\Pragmatic.SourceGenerator.csproj"
OutputItemType="Analyzer"
ReferenceOutputAssembly="false" />

// Register HybridCache (required) + Pragmatic.Caching
builder.Services.AddHybridCache();
builder.Services.AddPragmaticCaching();
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);
});
});
[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() — returns CacheEntryOptions with duration, tags, priority
  • CacheCategory — returns the category type if Category is specified
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);
[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().


Marks a type for cache key and options generation. Applied to DomainAction queries or standalone types.

PropertyTypeDefaultDescription
Durationstring"5m"Cache duration. Formats: "5m", "1h", "1d", or TimeSpan string
Tagsstring[]?nullTags for group invalidation. Supports {Property} placeholders
SlidingboolfalseWhen true, uses sliding expiration (resets on access)
PriorityCachePriorityNormalEviction priority: Low, Normal, High, NeverRemove
CategoryType?nullCache 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>().

Controls how a property contributes to cache key generation. Applied to properties within a [Cacheable] type.

PropertyTypeDefaultDescription
Namestring?nullCustom name in the key segment. Default: property name
ExcludeboolfalseWhen true, excludes this property from the key
Orderintint.MaxValueKey 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}"

Marks a domain event or type for cache invalidation generation.

PropertyTypeDefaultDescription
Tagsstring[][]Tags to invalidate. Supports {Property} placeholders
Keysstring[]?nullExplicit cache keys to remove
CategoryType?nullTarget 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; }
}

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)”
CategoryMarker TypeUsed ByPurpose
DefaultCacheCategories.DefaultGeneral business queriesDefault when no category is specified
OutputCacheCacheCategories.OutputCachePragmaticOutputCacheStoreHTTP response caching
RateLimitingCacheCategories.RateLimitingPragmaticDistributedRateLimiterCross-instance rate limit counters
PermissionsCacheCategories.PermissionsCachedPermissionResolverCross-request permission set caching
ConfigurationCacheCategories.ConfigurationConfigurationResolverRemote configuration value caching

Define application-specific categories as sealed marker classes:

public static class AppCacheCategories
{
public sealed class Analytics;
public sealed class UserSessions;
}

Configure categories via AddPragmaticCaching(Action<CachingBuilder>):

MethodDescription
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:

PropertyTypeDefaultDescription
KeyPrefixstringAuto-generated from type nameKey prefix for category isolation
DefaultDurationTimeSpan?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.ForCategory<TCategory>(IServiceProvider) resolves the correct ICacheStack:

  1. Try to resolve a keyed ICacheStack with key typeof(TCategory).FullName
  2. If not found, fall back to the default (unkeyed) ICacheStack
// Resolve category-specific cache
var permCache = CacheStackProvider.ForCategory<CacheCategories.Permissions>(sp);
// In bridges (Output Cache, Rate Limiter), this happens automatically

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.


Generated on types marked with [Cacheable]:

public interface ICacheable
{
string GetCacheKey();
CacheEntryOptions GetCacheOptions();
Type? CacheCategory => null; // Default interface method; overridden when Category is specified
}

Generated on types marked with [InvalidatesCache]:

public interface ICacheInvalidator
{
ValueTask InvalidateAsync(ICacheStack cache, CancellationToken ct = default);
Type? InvalidationCategory => null; // null = broadcast to all categories
}

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

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.

Wraps an ICacheStack adding a key prefix to all operations. Created automatically by ForCategory<T>(). Prefixes both keys and tags for full category isolation.

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

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

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 hits
  • pragmatic.cache.misses — counter of cache misses
  • pragmatic.cache.sets — counter of set operations
  • pragmatic.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.


With ModuleIntegration
Pragmatic.EndpointsPragmaticOutputCacheStore bridges ASP.NET Output Cache to ICacheStack via CacheCategories.OutputCache. PragmaticDistributedRateLimiter uses CacheCategories.RateLimiting for cross-instance counters.
Pragmatic.AuthorizationCachedPermissionResolver uses ICacheStack (optionally injected) for cross-request permission caching. Uses CacheCategories.Permissions for isolation.
Pragmatic.ConfigurationRemote configuration values cached via ICacheStack using CacheCategories.Configuration. Replaces the former IConfigurationCache interface.
Pragmatic.Actions[Cacheable] on DomainAction enables invoker-level result caching
Pragmatic.PersistenceQuery results cached with entity-tag invalidation on mutations
Pragmatic.EventsDomain events with [InvalidatesCache] clear stale entries

IDSeverityDescription
PRAG1700Error[Cacheable] type must be partial
PRAG1701ErrorInvalid cache duration format
PRAG1702ErrorNo cache key properties found
PRAG1703ErrorTag/key placeholder references non-existent property
PRAG1750WarningDuplicate [CacheKey(Order)] values
PRAG1751WarningAll properties excluded from cache key

Pragmatic.Abstractions (Caching namespace)

Section titled “Pragmatic.Abstractions (Caching namespace)”
TypeKindPurpose
ICacheStackInterfaceUnified caching abstraction (get/set/remove/invalidate)
CacheEntryOptionsClassEntry options: duration, sliding, tags, priority
CachePriorityEnumEviction priority (Low, Normal, High, NeverRemove)
CacheCategoriesStatic classPredefined category marker types
CacheCategories.DefaultClassDefault cache category
CacheCategories.OutputCacheClassHTTP response caching category
CacheCategories.RateLimitingClassRate limit counter category
CacheCategories.PermissionsClassPermission caching category
CacheCategories.ConfigurationClassConfiguration value caching category
TypeKindPurpose
HybridCacheStackClassDefault ICacheStack (L1 memory + L2 distributed via HybridCache)
CachingBuilderClassFluent builder for per-category routing configuration
CacheStackProviderStatic classResolves category-specific ICacheStack (keyed, fallback to default)
CategoryCacheOptionsClassPer-category configuration (KeyPrefix, DefaultDuration)
CachingOptionsClassGlobal configuration (DefaultDuration, KeyPrefix, etc.)
ICacheableInterfaceGenerated on [Cacheable] types: GetCacheKey, GetCacheOptions, CacheCategory
ICacheInvalidatorInterfaceGenerated on [InvalidatesCache] types: InvalidateAsync, InvalidationCategory
CacheableAttributeAttributeMarks a type as cacheable (Duration, Tags, Sliding, Priority, Category)
InvalidatesCacheAttributeAttributeMarks a type for invalidation (Tags, Keys, Category)
CacheKeyAttributeAttributeCustomizes property contribution to cache key (Name, Exclude, Order)
CachingDiagnosticsStatic classOTel ActivitySource and Meter instruments

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.

  • .NET 10.0+
  • Microsoft.Extensions.Caching.Hybrid
  • Pragmatic.SourceGenerator analyzer

Part of the Pragmatic.Design ecosystem.