Skip to content

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.


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.

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.

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 data
await cache.SetStringAsync("user:42", serializedPermissions, fiveMinutes);
// Output cache: 30-second TTL, public data
await cache.SetStringAsync("user:42", serializedHtml, thirtySeconds);
// Rate limiter: 1-minute window, counter
await 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.


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() — returns CacheEntryOptions with 5-minute absolute duration and the expanded tags ["products", "product:42"]
  • CacheCategory — returns the category type if Category is specified
  • GetProductByIdCacheKeys.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.


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 TTL

Steps 1, 2, and 4 are fully source-generated. Steps 3 and 5 are runtime operations backed by HybridCacheStack and PrefixedCacheStack.


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?”
ConcernIDistributedCacheICacheStack
Stampede protectionNone — concurrent misses all call the factoryBuilt-in via HybridCache.GetOrCreateAsync
Tag invalidationNot supportedInvalidateByTagAsync, InvalidateByTagsAsync
Typed valuesbyte[] only — manual serializationGeneric T with automatic serialization
Hit/miss detectionReturn null — ambiguous for nullable typesTryGetAsync returns (bool Found, T? Value)
L1+L2 tieringSeparate IMemoryCache + IDistributedCacheUnified via HybridCache backend
ObservabilityNone built-inActivities, metrics, structured logging

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 IDistributedCache backend.
  • 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.

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.

Controls eviction behavior under memory pressure:

PriorityBehavior
LowFirst to be evicted when memory pressure occurs
NormalDefault. Standard eviction behavior
HighLess likely to be evicted. Use for expensive-to-compute values
NeverRemoveNever auto-evicted. Only expires by duration. Use sparingly

Three attributes control the source generator’s output.

Marks a type for cache key and options generation. Applied to query classes, DomainActions, or standalone types.

PropertyTypeDefaultDescription
Durationstring"5m"Cache duration. Formats: "30s", "5m", "1h", "1d", or TimeSpan string "00:15:00"
Tagsstring[]?nullTags for group invalidation. Supports {Property} placeholders
SlidingboolfalseWhen true, uses sliding expiration (resets on access)
PriorityCachePriorityNormalEviction priority under memory pressure
CategoryType?nullCache category marker type for routing to a specific ICacheStack

The type must be partial (diagnostic PRAG1700 if not).

Customizes how a property contributes to the generated cache key. Applied to individual 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.

Marks a type for cache invalidation generation. Applied to domain events or mutation types.

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


Tags are the mechanism for invalidating groups of related cache entries without tracking individual keys.

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.

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.

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.


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.

Without categories, all cache entries share a single ICacheStack. This causes three problems:

  1. Key collisions: An output cache entry user:42 and a permission cache entry user:42 overwrite each other.
  2. Configuration bleed: A 30-minute TTL for permission sets is too long for rate limiter counters, but both share the same default duration.
  3. Invalidation noise: Invalidating user:42 for 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.

Five categories are predefined in Pragmatic.Abstractions so any module can reference them without depending on Pragmatic.Caching:

CategoryMarker TypeConsumerPurpose
DefaultCacheCategories.DefaultBusiness queries/actionsGeneral-purpose (used when no category is specified)
OutputCacheCacheCategories.OutputCachePragmaticOutputCacheStoreASP.NET Core HTTP 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;
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 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.

Each registered category creates a PrefixedCacheStack (internal) that wraps the default ICacheStack. It:

  1. Prepends KeyPrefix to all cache keys
  2. Prepends KeyPrefix to all tags (for invalidation isolation)
  3. Applies DefaultDuration when 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.

PropertyTypeDefaultDescription
KeyPrefixstringAuto-generated from type namePrefix for all keys and tags in this category
DefaultDurationTimeSpan?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.

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.

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


For each type marked with caching attributes, the source generator produces one or more files. The exact output depends on the attribute and properties.

Generated OutputContentCondition
{Type}.Cache.g.csPartial class implementing ICacheable: GetCacheKey(), GetCacheOptions(), CacheCategoryAlways
{Type}CacheKeys classStatic helper with Create(...) method for external key constructionAlways (in same file)

Example: for a GetProductById class with ProductId property:

GetProductById.Cache.g.cs
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 allocation
public CacheEntryOptions GetCacheOptions() => new()
{
Duration = TimeSpan.FromSeconds(300),
Tags = ["products", $"product:{ProductId}"]
};
Generated OutputContentCondition
{Type}.CacheInvalidator.g.csPartial class implementing ICacheInvalidator: InvalidateAsync(), InvalidationCategoryAlways

Example: for a ProductUpdated class:

ProductUpdated.CacheInvalidator.g.cs
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.

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.


AddPragmaticCaching has three overloads:

// 1. Defaults only — no categories
services.AddPragmaticCaching();
// 2. Configure global options
services.AddPragmaticCaching(options =>
{
options.DefaultDuration = TimeSpan.FromMinutes(10);
options.KeyPrefix = "myapp:";
});
// 3. Full builder — global options + per-category routing
services.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.

PropertyTypeDefaultDescription
DefaultDurationTimeSpan5 minutesDefault TTL when not specified on the entry
EnableQueryCachingbooltrueAuto-cache [Cacheable] types
EnableEventInvalidationbooltrueAuto-invalidate on [InvalidatesCache]
KeyPrefixstring""Global prefix for all keys (multi-tenant isolation)

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


HybridCacheStack provides built-in OpenTelemetry integration for every cache operation.

Every operation creates an Activity from CachingDiagnostics.ActivitySource (source name: "Pragmatic.Caching"):

Activity NameOperationTags
Cache.GetOrSetGet-or-set with factorypragmatic.cache.key, pragmatic.cache.operation
Cache.TryGetTry-get with hit/miss detectionpragmatic.cache.key, cache.hit
Cache.SetExplicit setpragmatic.cache.key
Cache.RemoveRemove by keypragmatic.cache.key
Cache.InvalidateByTagSingle tag invalidationpragmatic.cache.tags
Cache.InvalidateByTagsMulti-tag invalidationpragmatic.cache.tags

Four counters are exposed via System.Diagnostics.Metrics (meter name: "Pragmatic.Caching"):

MetricTypeDescription
pragmatic.cache.hitsCounterTotal cache hits (from TryGetAsync)
pragmatic.cache.missesCounterTotal cache misses (from TryGetAsync)
pragmatic.cache.setsCounterTotal explicit set operations
pragmatic.cache.invalidationsCounterTotal invalidations (remove + tag)

HybridCacheStack uses [LoggerMessage] source-generated partial methods for zero-allocation structured logging:

LevelMessage
DebugCache get-or-set key='{key}'
DebugCache hit key='{key}'
DebugCache miss key='{key}', executing factory
DebugCache set key='{key}'
DebugCache remove key='{key}'
InformationCache invalidate tag='{tag}'
InformationCache invalidating {tagCount} tag(s)
WarningCache tag invalidation failed for tag='{tag}'

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.

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.

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.

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.

Two bridges connect Pragmatic.Caching to ASP.NET Core infrastructure:

  • Output Cache Bridge: PragmaticOutputCacheStore implements IOutputCacheStore, routing HTTP response caching through ICacheStack via CacheCategories.OutputCache. Register with services.UseOutputCacheFromPragmaticCaching().
  • Rate Limiter Bridge: PragmaticDistributedRateLimiter implements RateLimiter, using ICacheStack via CacheCategories.RateLimiting for cross-instance fixed-window rate limit counters.

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.

Remote configuration values are cached via ICacheStack using CacheCategories.Configuration. This replaces the former IConfigurationCache interface with a unified caching approach.


ScenarioApproach
Cache a query/action result with typed key[Cacheable] + ICacheStack.GetOrSetAsync
Invalidate on domain events[InvalidatesCache] on the event class
Manual cache with custom logicInject ICacheStack directly, call SetAsync/RemoveAsync
Different TTLs for different subsystemsCategory routing with ForCategory<T>()
Sliding expiration (reset on access)[Cacheable(Sliding = true)]
Keep value under memory pressure[Cacheable(Priority = CachePriority.High)]
Multi-tenant key isolationGlobal KeyPrefix in CachingOptions, or {TenantId} in tags
Invalidate one subsystem only[InvalidatesCache(Category = typeof(...))]
HTTP response cachingOutput Cache Bridge via CacheCategories.OutputCache
External key constructionUse generated {Type}CacheKeys.Create(...) static helper