Architecture and Core Concepts
This guide explains why Pragmatic.Caching exists, how its pieces fit together, and how to choose the right approach for each caching scenario. Read this before diving into the individual feature guides.
The Problem
Section titled “The Problem”Caching is deceptively simple in demos and deceptively painful in production. The .NET ecosystem provides the building blocks — IMemoryCache, IDistributedCache, HybridCache — but leaves three critical concerns entirely to the developer.
Problem 1: Magic Strings Everywhere
Section titled “Problem 1: Magic Strings Everywhere”public class ProductService(IDistributedCache cache, IProductRepository repo){ public async Task<ProductDto?> GetByIdAsync(int id, CancellationToken ct) { var key = $"product:{id}"; // Magic string — no compile-time validation 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; }}The cache key "product:{id}" is a magic string. Rename the entity from Product to Catalog and you must find-and-replace every occurrence across the codebase. Miss one, and you get stale data served from the old key forever. The 5-minute duration is hardcoded — change it in one place, forget another.
Problem 2: Invalidation is Ad-Hoc
Section titled “Problem 2: Invalidation is Ad-Hoc”public class ProductCommandHandler(IDistributedCache cache){ public async Task HandleProductUpdated(ProductUpdated evt, CancellationToken ct) { // Must manually know every key pattern that references this product await cache.RemoveAsync($"product:{evt.ProductId}", ct); await cache.RemoveAsync($"products:tenant:{evt.TenantId}", ct); await cache.RemoveAsync($"products:category:{evt.CategoryId}", ct); // Did we miss "products:search:*"? Probably. }}Every new query that caches product data requires updating every invalidation handler that touches products. This coupling grows linearly with the number of cached queries, and missed invalidations produce stale data bugs that are notoriously difficult to reproduce.
Problem 3: No Isolation Between Subsystems
Section titled “Problem 3: No Isolation Between Subsystems”// Permission cache: 5-minute TTL, sensitive dataawait cache.SetStringAsync("user:42", serializedPermissions, fiveMinutes);
// Output cache: 30-second TTL, public dataawait cache.SetStringAsync("user:42", serializedHtml, thirtySeconds);
// Rate limiter: 1-minute window, counterawait cache.SetStringAsync("user:42", "7", oneMinute);Three subsystems using the same "user:42" key. The last write wins, corrupting the other two. Even with distinct key prefixes, invalidating "user:42" in the permission subsystem should not evict the output cache entry — but with a shared backend, it does.
The fundamental issue: the .NET caching primitives handle storage and retrieval, but the developer must manually maintain key generation, invalidation correctness, and subsystem isolation. These are exactly the concerns that a source generator can automate.
The Solution
Section titled “The Solution”Pragmatic.Caching inverts the model. You declare what to cache and when to invalidate using attributes, and the source generator produces the key generation, options construction, and invalidation methods at compile time.
The same product caching scenario:
[Cacheable(Duration = "5m", Tags = ["products", "product:{ProductId}"])]public partial class GetProductById{ public required int ProductId { get; init; }}The source generator reads this class and produces:
GetCacheKey()— returns"GetProductById:ProductId=42"(typed, no magic strings)GetCacheOptions()— returnsCacheEntryOptionswith 5-minute absolute duration and the expanded tags["products", "product:42"]CacheCategory— returns the category type ifCategoryis specifiedGetProductByIdCacheKeys.Create(productId)— static helper for external key construction
For invalidation:
[InvalidatesCache("products", "product:{ProductId}")]public partial class ProductUpdated{ public int ProductId { get; init; }}The generator produces InvalidateAsync(ICacheStack cache, CancellationToken ct) that expands {ProductId} to the actual value and calls cache.InvalidateByTagAsync for each tag. No manual tracking of key patterns.
For subsystem isolation, category routing gives each subsystem its own ICacheStack with a key prefix, independent default duration, and isolated tag namespace:
builder.Services.AddPragmaticCaching(cache =>{ cache.ForCategory<CacheCategories.Permissions>(o => { o.KeyPrefix = "perms:"; o.DefaultDuration = TimeSpan.FromMinutes(5); }); cache.ForCategory<CacheCategories.RateLimiting>(o => { o.KeyPrefix = "rl:"; o.DefaultDuration = TimeSpan.FromMinutes(1); });});No reflection at runtime. No magic strings. No manual invalidation wiring. The generated code is visible in your IDE under obj/, fully debuggable.
How It Works: The Caching Lifecycle
Section titled “How It Works: The Caching Lifecycle”Every cached value in Pragmatic.Caching flows through a deterministic lifecycle. The source generator produces the code for steps 1-3 at compile time based on your attributes.
1. Key Generation [Cacheable] --> GetCacheKey() SG builds key from type name + property values "GetProductById:ProductId=42"
2. Options Construction [Cacheable] --> GetCacheOptions() Duration, tags (with placeholder expansion), sliding vs absolute, priority Static allocation when tags are constants
3. Cache Operation ICacheStack.GetOrSetAsync(key, factory, options) HybridCacheStack: L1 memory + L2 distributed Stampede protection: one factory call per key
4. Tag-Based Invalidation [InvalidatesCache] --> InvalidateAsync() SG expands placeholders, calls InvalidateByTagAsync Per-category or broadcast across all categories
5. Category Routing CacheStackProvider.ForCategory<T>(sp) Resolves keyed ICacheStack for subsystem isolation PrefixedCacheStack adds key/tag prefix + default TTLSteps 1, 2, and 4 are fully source-generated. Steps 3 and 5 are runtime operations backed by HybridCacheStack and PrefixedCacheStack.
ICacheStack: The Core Abstraction
Section titled “ICacheStack: The Core Abstraction”ICacheStack is the unified caching interface, defined in Pragmatic.Abstractions (Layer 0). It provides six operations that cover all caching scenarios:
public interface ICacheStack{ // Get-or-set with stampede protection ValueTask<T> GetOrSetAsync<T>(string key, Func<CancellationToken, ValueTask<T>> factory, CacheEntryOptions? options = null, CancellationToken ct = default);
// Direct get (returns default on miss) ValueTask<T?> GetAsync<T>(string key, CancellationToken ct = default);
// Try-get (distinguishes miss from cached null) ValueTask<(bool Found, T? Value)> TryGetAsync<T>(string key, CancellationToken ct = default);
// Explicit set ValueTask SetAsync<T>(string key, T value, CacheEntryOptions? options = null, CancellationToken ct = default);
// Remove by key ValueTask RemoveAsync(string key, CancellationToken ct = default);
// Invalidate by tag(s) ValueTask InvalidateByTagAsync(string tag, CancellationToken ct = default); ValueTask InvalidateByTagsAsync(IEnumerable<string> tags, CancellationToken ct = default);}Why ICacheStack instead of IDistributedCache?
Section titled “Why ICacheStack instead of IDistributedCache?”| Concern | IDistributedCache | ICacheStack |
|---|---|---|
| Stampede protection | None — concurrent misses all call the factory | Built-in via HybridCache.GetOrCreateAsync |
| Tag invalidation | Not supported | InvalidateByTagAsync, InvalidateByTagsAsync |
| Typed values | byte[] only — manual serialization | Generic T with automatic serialization |
| Hit/miss detection | Return null — ambiguous for nullable types | TryGetAsync returns (bool Found, T? Value) |
| L1+L2 tiering | Separate IMemoryCache + IDistributedCache | Unified via HybridCache backend |
| Observability | None built-in | Activities, metrics, structured logging |
HybridCacheStack
Section titled “HybridCacheStack”The default ICacheStack implementation. It wraps Microsoft.Extensions.Caching.Hybrid.HybridCache, providing:
- L1 (in-memory): Fast reads, process-local. No serialization cost.
- L2 (distributed): Cross-instance consistency via Redis, SQL Server, or any
IDistributedCachebackend. - Stampede protection: When 10 concurrent requests miss the cache for the same key, only one factory call executes. The other 9 await the result.
The tiering is transparent. GetOrSetAsync checks L1 first, then L2, and only calls the factory on a complete miss.
CacheEntryOptions
Section titled “CacheEntryOptions”Every cache operation optionally accepts CacheEntryOptions:
public sealed class CacheEntryOptions{ public TimeSpan? Duration { get; init; } // Absolute expiration public TimeSpan? SlidingDuration { get; init; } // Sliding expiration (resets on access) public ImmutableArray<string> Tags { get; init; } // Tags for group invalidation public CachePriority Priority { get; init; } // Eviction priority under memory pressure
public static CacheEntryOptions Default { get; } // 5-minute absolute duration public static CacheEntryOptions WithDuration(TimeSpan d); public static CacheEntryOptions WithSliding(TimeSpan d);}When the source generator produces GetCacheOptions(), it returns a CacheEntryOptions instance with the values from your [Cacheable] attribute. If all tags are constants (no {Property} placeholders), the generated code uses a static readonly field to avoid per-call allocation.
CachePriority
Section titled “CachePriority”Controls eviction behavior under memory pressure:
| Priority | Behavior |
|---|---|
Low | First to be evicted when memory pressure occurs |
Normal | Default. Standard eviction behavior |
High | Less likely to be evicted. Use for expensive-to-compute values |
NeverRemove | Never auto-evicted. Only expires by duration. Use sparingly |
Cache Attributes
Section titled “Cache Attributes”Three attributes control the source generator’s output.
[Cacheable]
Section titled “[Cacheable]”Marks a type for cache key and options generation. Applied to query classes, DomainActions, or standalone types.
| Property | Type | Default | Description |
|---|---|---|---|
Duration | string | "5m" | Cache duration. Formats: "30s", "5m", "1h", "1d", or TimeSpan string "00:15:00" |
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 under memory pressure |
Category | Type? | null | Cache category marker type for routing to a specific ICacheStack |
The type must be partial (diagnostic PRAG1700 if not).
[CacheKey]
Section titled “[CacheKey]”Customizes how a property contributes to the generated cache key. Applied to individual 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.
[InvalidatesCache]
Section titled “[InvalidatesCache]”Marks a type for cache invalidation generation. Applied to domain events or mutation types.
| Property | Type | Default | Description |
|---|---|---|---|
Tags | string[] | [] | Tags to invalidate. Supports {Property} placeholders. Empty = convention-based |
Keys | string[]? | null | Explicit cache keys to remove |
Category | Type? | null | Target category. null = broadcast to ALL categories |
Convention-based invalidation: when no tags are specified, the generator derives a tag from the event name. For example, ProductUpdated invalidates the "products" tag.
Tag-Based Invalidation
Section titled “Tag-Based Invalidation”Tags are the mechanism for invalidating groups of related cache entries without tracking individual keys.
How Tags Work
Section titled “How Tags Work”When you cache a value with tags, those tags are recorded alongside the entry:
[Cacheable(Duration = "10m", Tags = ["products", "tenant:{TenantId}"])]public partial class GetProductsByTenant{ public required int TenantId { get; init; }}For TenantId = 42, the generated GetCacheOptions() expands the tags to ["products", "tenant:42"]. When any invalidation targets the "products" tag or the "tenant:42" tag, this entry is evicted.
Placeholder Expansion
Section titled “Placeholder Expansion”Tag placeholders use the {PropertyName} syntax. The source generator validates at compile time that the referenced property exists on the type (diagnostic PRAG1703 if not).
// Cacheable side — tags stored with the entry[Cacheable(Duration = "5m", Tags = ["users", "user:{UserId}", "tenant:{TenantId}"])]public partial class GetUserProfile{ public required Guid UserId { get; init; } public required int TenantId { get; init; }}
// Invalidation side — tags expanded and invalidated[InvalidatesCache("user:{UserId}")]public partial class UserProfileUpdated{ public Guid UserId { get; init; }}When UserProfileUpdated.InvalidateAsync runs with UserId = abc-123, it calls cache.InvalidateByTagAsync("user:abc-123"). Every cached entry tagged with "user:abc-123" is evicted, regardless of which query type created it.
Broadcast vs Targeted Invalidation
Section titled “Broadcast vs Targeted Invalidation”By default, [InvalidatesCache] broadcasts to all registered cache categories. This means the invalidation reaches every subsystem’s ICacheStack. Set Category to target a specific subsystem:
// Broadcast: invalidates across ALL categories[InvalidatesCache("users:{UserId}")]public partial class UserUpdated{ public Guid UserId { get; init; }}
// Targeted: only invalidates within the Permissions category[InvalidatesCache("user:{UserId}", Category = typeof(CacheCategories.Permissions))]public partial class UserRolesChanged{ public Guid UserId { get; init; }}Use targeted invalidation when you know that only one subsystem’s cache is affected. This avoids unnecessary evictions in other categories.
Category Routing
Section titled “Category Routing”Categories solve the subsystem isolation problem by giving each subsystem its own ICacheStack instance with a key prefix, independent default duration, and isolated tag namespace.
Why Categories
Section titled “Why Categories”Without categories, all cache entries share a single ICacheStack. This causes three problems:
- Key collisions: An output cache entry
user:42and a permission cache entryuser:42overwrite each other. - Configuration bleed: A 30-minute TTL for permission sets is too long for rate limiter counters, but both share the same default duration.
- Invalidation noise: Invalidating
user:42for permissions should not evict the output cache entry.
Categories solve all three: each category’s PrefixedCacheStack adds a prefix to all keys and tags, applies its own default duration, and creates an isolated namespace.
Predefined Categories
Section titled “Predefined Categories”Five categories are predefined in Pragmatic.Abstractions so any module can reference them without depending on Pragmatic.Caching:
| Category | Marker Type | Consumer | Purpose |
|---|---|---|---|
| Default | CacheCategories.Default | Business queries/actions | General-purpose (used when no category is specified) |
| OutputCache | CacheCategories.OutputCache | PragmaticOutputCacheStore | ASP.NET Core 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; public sealed class Recommendations;}Register them with per-category configuration:
builder.Services.AddPragmaticCaching(cache =>{ cache.ForCategory<AppCacheCategories.Analytics>(o => { o.KeyPrefix = "analytics:"; o.DefaultDuration = TimeSpan.FromHours(1); }); cache.ForCategory<AppCacheCategories.UserSessions>(o => { o.KeyPrefix = "sessions:"; o.DefaultDuration = TimeSpan.FromMinutes(30); });});Use them on cacheable types:
[Cacheable(Duration = "1h", Category = typeof(AppCacheCategories.Analytics))]public partial class GetDashboardMetrics{ public required DateOnly Date { get; init; }}CacheStackProvider Resolution Flow
Section titled “CacheStackProvider Resolution Flow”CacheStackProvider resolves the correct ICacheStack for a given category:
CacheStackProvider.ForCategory<TCategory>(sp) | +--> sp.GetKeyedService<ICacheStack>(typeof(TCategory).FullName) | | | +--> Found? Return keyed ICacheStack (PrefixedCacheStack) | +--> Fallback: sp.GetRequiredService<ICacheStack>() (default HybridCacheStack)Category-specific registrations use .NET 8+ keyed services. The service key is typeof(TCategory).FullName.
PrefixedCacheStack
Section titled “PrefixedCacheStack”Each registered category creates a PrefixedCacheStack (internal) that wraps the default ICacheStack. It:
- Prepends
KeyPrefixto all cache keys - Prepends
KeyPrefixto all tags (for invalidation isolation) - Applies
DefaultDurationwhen no explicit duration is set on the entry
Example: with prefix "perms:", a key "user:42" becomes "perms:user:42", and a tag "user:42" becomes "perms:user:42". This guarantees that permission cache invalidation never touches entries in other categories.
CategoryCacheOptions
Section titled “CategoryCacheOptions”| Property | Type | Default | Description |
|---|---|---|---|
KeyPrefix | string | Auto-generated from type name | Prefix for all keys and tags in this category |
DefaultDuration | TimeSpan? | null (inherits global) | Default TTL for entries without explicit duration |
If KeyPrefix is not explicitly set, it auto-generates from the category type name in lowercase (e.g., OutputCache becomes outputcache:, Permissions becomes permissions:).
ICacheable and ICacheInvalidator Interfaces
Section titled “ICacheable and ICacheInvalidator Interfaces”The source generator implements these interfaces on your types.
ICacheable
Section titled “ICacheable”Generated on types marked with [Cacheable]:
public interface ICacheable{ string GetCacheKey(); CacheEntryOptions GetCacheOptions(); Type? CacheCategory => null; // Overridden when Category is specified}This interface enables the runtime to work with any cacheable type generically — for example, the DomainAction invoker can detect ICacheable on an action and automatically cache results.
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}The InvalidationCategory default interface method returns null, meaning broadcast. When Category is specified on the attribute, the generated override returns typeof(TCategory).
What Gets Generated
Section titled “What Gets Generated”For each type marked with caching attributes, the source generator produces one or more files. The exact output depends on the attribute and properties.
For [Cacheable] types
Section titled “For [Cacheable] types”| Generated Output | Content | Condition |
|---|---|---|
{Type}.Cache.g.cs | Partial class implementing ICacheable: GetCacheKey(), GetCacheOptions(), CacheCategory | Always |
{Type}CacheKeys class | Static helper with Create(...) method for external key construction | Always (in same file) |
Example: for a GetProductById class with ProductId property:
public partial class GetProductById : ICacheable{ private static readonly CacheEntryOptions __cacheOptions = new() { Duration = TimeSpan.FromSeconds(300), Tags = ["products"] };
[MethodImpl(MethodImplOptions.AggressiveInlining)] public string GetCacheKey() => $"GetProductById:ProductId={ProductId}";
[MethodImpl(MethodImplOptions.AggressiveInlining)] public CacheEntryOptions GetCacheOptions() => __cacheOptions;}
public static partial class GetProductByIdCacheKeys{ [MethodImpl(MethodImplOptions.AggressiveInlining)] public static string Create(int productId) => $"GetProductById:ProductId={productId}";}When tags contain placeholders (e.g., "product:{ProductId}"), GetCacheOptions() cannot use a static field because tags vary per instance. The generated code allocates a new CacheEntryOptions per call:
// When tags have placeholders — per-call allocationpublic CacheEntryOptions GetCacheOptions() => new(){ Duration = TimeSpan.FromSeconds(300), Tags = ["products", $"product:{ProductId}"]};For [InvalidatesCache] types
Section titled “For [InvalidatesCache] types”| Generated Output | Content | Condition |
|---|---|---|
{Type}.CacheInvalidator.g.cs | Partial class implementing ICacheInvalidator: InvalidateAsync(), InvalidationCategory | Always |
Example: for a ProductUpdated class:
public partial class ProductUpdated : ICacheInvalidator{ public async ValueTask InvalidateAsync(ICacheStack cache, CancellationToken ct = default) { await cache.InvalidateByTagAsync("products", ct).ConfigureAwait(false); await cache.InvalidateByTagAsync($"product:{this.ProductId}", ct).ConfigureAwait(false); }}When Category is specified, the generated InvalidationCategory property returns the category type, allowing the runtime to route the invalidation to the correct ICacheStack.
Generated file location
Section titled “Generated file location”All generated files live under obj/Debug/net10.0/generated/ and are visible in the IDE under Dependencies > Analyzers > Pragmatic.SourceGenerator. You can set breakpoints in generated code.
CachingBuilder and Service Registration
Section titled “CachingBuilder and Service Registration”Registration Overloads
Section titled “Registration Overloads”AddPragmaticCaching has three overloads:
// 1. Defaults only — no categoriesservices.AddPragmaticCaching();
// 2. Configure global optionsservices.AddPragmaticCaching(options =>{ options.DefaultDuration = TimeSpan.FromMinutes(10); options.KeyPrefix = "myapp:";});
// 3. Full builder — global options + per-category routingservices.AddPragmaticCaching(cache =>{ cache.WithDefaultOptions(o => o.DefaultDuration = TimeSpan.FromMinutes(10)); cache.ForCategory<CacheCategories.Permissions>(o => { o.KeyPrefix = "perms:"; o.DefaultDuration = TimeSpan.FromMinutes(5); });});All overloads register ICacheStack as HybridCacheStack (singleton). The builder overload additionally registers keyed ICacheStack instances for each category.
CachingOptions (Global)
Section titled “CachingOptions (Global)”| Property | Type | Default | Description |
|---|---|---|---|
DefaultDuration | TimeSpan | 5 minutes | Default TTL when not specified on the entry |
EnableQueryCaching | bool | true | Auto-cache [Cacheable] types |
EnableEventInvalidation | bool | true | Auto-invalidate on [InvalidatesCache] |
KeyPrefix | string | "" | Global prefix for all keys (multi-tenant isolation) |
SG Auto-Detection
Section titled “SG Auto-Detection”The source generator detects category usage from [Cacheable(Category = typeof(...))] and [InvalidatesCache(Category = typeof(...))] across referenced assemblies. In host mode (with Pragmatic.Composition), the generated RegisterAllPragmaticServices() method auto-registers ForCategory<T>() for every detected category:
// Auto-generated in PragmaticHost.g.csservices.AddPragmaticCaching(cache =>{ cache.ForCategory<CacheCategories.Permissions>(_ => { }); cache.ForCategory<AppCacheCategories.Analytics>(_ => { });});The empty callback (_ => { }) uses auto-generated defaults. The user can override these with explicit configuration in Program.cs (DI last-registration-wins).
Observability
Section titled “Observability”HybridCacheStack provides built-in OpenTelemetry integration for every cache operation.
Distributed Tracing (Activities)
Section titled “Distributed Tracing (Activities)”Every operation creates an Activity from CachingDiagnostics.ActivitySource (source name: "Pragmatic.Caching"):
| Activity Name | Operation | Tags |
|---|---|---|
Cache.GetOrSet | Get-or-set with factory | pragmatic.cache.key, pragmatic.cache.operation |
Cache.TryGet | Try-get with hit/miss detection | pragmatic.cache.key, cache.hit |
Cache.Set | Explicit set | pragmatic.cache.key |
Cache.Remove | Remove by key | pragmatic.cache.key |
Cache.InvalidateByTag | Single tag invalidation | pragmatic.cache.tags |
Cache.InvalidateByTags | Multi-tag invalidation | pragmatic.cache.tags |
Metrics (Counters)
Section titled “Metrics (Counters)”Four counters are exposed via System.Diagnostics.Metrics (meter name: "Pragmatic.Caching"):
| Metric | Type | Description |
|---|---|---|
pragmatic.cache.hits | Counter | Total cache hits (from TryGetAsync) |
pragmatic.cache.misses | Counter | Total cache misses (from TryGetAsync) |
pragmatic.cache.sets | Counter | Total explicit set operations |
pragmatic.cache.invalidations | Counter | Total invalidations (remove + tag) |
Structured Logging
Section titled “Structured Logging”HybridCacheStack uses [LoggerMessage] source-generated partial methods for zero-allocation structured logging:
| Level | Message |
|---|---|
| Debug | Cache get-or-set key='{key}' |
| Debug | Cache hit key='{key}' |
| Debug | Cache miss key='{key}', executing factory |
| Debug | Cache set key='{key}' |
| Debug | Cache remove key='{key}' |
| Information | Cache invalidate tag='{tag}' |
| Information | Cache invalidating {tagCount} tag(s) |
| Warning | Cache tag invalidation failed for tag='{tag}' |
Ecosystem Integration Overview
Section titled “Ecosystem Integration Overview”Pragmatic.Caching is a cross-cutting concern that integrates with several other Pragmatic modules. Each integration is opt-in: reference the package, and the SG or runtime detects it.
Actions
Section titled “Actions”When a DomainAction or Query class is marked with [Cacheable], the invoker pipeline can automatically cache results. The ICacheable interface on the action tells the invoker to call GetCacheKey() and GetCacheOptions() before executing the handler, and to cache the result on a successful response.
Persistence
Section titled “Persistence”Query results can be cached with entity-tag invalidation. When a mutation modifies an entity, the generated [InvalidatesCache] handler on the corresponding domain event clears all cached queries tagged with that entity type.
Events
Section titled “Events”Domain events marked with [InvalidatesCache] integrate with the event dispatcher. When the event fires, the generated InvalidateAsync method is called automatically, clearing stale cache entries.
Endpoints (ASP.NET Core Bridges)
Section titled “Endpoints (ASP.NET Core Bridges)”Two bridges connect Pragmatic.Caching to ASP.NET Core infrastructure:
- Output Cache Bridge:
PragmaticOutputCacheStoreimplementsIOutputCacheStore, routing HTTP response caching throughICacheStackviaCacheCategories.OutputCache. Register withservices.UseOutputCacheFromPragmaticCaching(). - Rate Limiter Bridge:
PragmaticDistributedRateLimiterimplementsRateLimiter, usingICacheStackviaCacheCategories.RateLimitingfor cross-instance fixed-window rate limit counters.
Authorization
Section titled “Authorization”CachedPermissionResolver optionally accepts ICacheStack for cross-request permission caching. When UsePermissionCache() is configured, resolved permission sets are cached per user (and per tenant if multi-tenant), using CacheCategories.Permissions for isolation.
Configuration
Section titled “Configuration”Remote configuration values are cached via ICacheStack using CacheCategories.Configuration. This replaces the former IConfigurationCache interface with a unified caching approach.
Choosing the Right Approach
Section titled “Choosing the Right Approach”| Scenario | Approach |
|---|---|
| Cache a query/action result with typed key | [Cacheable] + ICacheStack.GetOrSetAsync |
| Invalidate on domain events | [InvalidatesCache] on the event class |
| Manual cache with custom logic | Inject ICacheStack directly, call SetAsync/RemoveAsync |
| Different TTLs for different subsystems | Category routing with ForCategory<T>() |
| Sliding expiration (reset on access) | [Cacheable(Sliding = true)] |
| Keep value under memory pressure | [Cacheable(Priority = CachePriority.High)] |
| Multi-tenant key isolation | Global KeyPrefix in CachingOptions, or {TenantId} in tags |
| Invalidate one subsystem only | [InvalidatesCache(Category = typeof(...))] |
| HTTP response caching | Output Cache Bridge via CacheCategories.OutputCache |
| External key construction | Use generated {Type}CacheKeys.Create(...) static helper |
See Also
Section titled “See Also”- Getting Started — Install, configure, and cache your first query
- Category Routing — Deep dive into subsystem isolation with categories
- Common Mistakes — Frequent errors with wrong/right/why format
- Troubleshooting — Problem/solution guide with diagnostics reference