Skip to content

Common Mistakes

These are the most common issues developers encounter when using Pragmatic.Caching. Each section shows the wrong approach, the correct approach, and explains why.


1. Forgetting partial on the Cacheable Type

Section titled “1. Forgetting partial on the Cacheable Type”

Wrong:

[Cacheable(Duration = "5m", Tags = ["products"])]
public class GetProductById
{
public required int ProductId { get; init; }
}

Compile result: PRAG1700 error — “Type ‘GetProductById’ must be declared as partial to use [Cacheable]”.

Right:

[Cacheable(Duration = "5m", Tags = ["products"])]
public partial class GetProductById
{
public required int ProductId { get; init; }
}

Why: The source generator emits GetCacheKey(), GetCacheOptions(), and the ICacheable interface implementation into a partial class. Without partial, the compiler cannot merge the generated code with your class. The same rule applies to [InvalidatesCache] types.


Wrong:

[Cacheable(Duration = "5 minutes")]
public partial class GetUsers { }
[Cacheable(Duration = "300")]
public partial class GetOrders { }

Compile result: PRAG1701 error — “Invalid cache duration format ‘5 minutes’”. The second example ("300") also fails because bare numbers without a suffix are not a valid format.

Right:

[Cacheable(Duration = "5m")]
public partial class GetUsers { }
[Cacheable(Duration = "300s")] // 300 seconds
public partial class GetOrders { }
[Cacheable(Duration = "00:05:00")] // TimeSpan format also works
public partial class GetProducts { }

Why: The source generator parses duration strings at compile time. Supported formats are:

FormatExampleMeaning
Ns"30s"N seconds
Nm"5m"N minutes
Nh"1h"N hours
Nd"1d"N days
TimeSpan string"00:15:00"15 minutes

Bare numbers, English words (“minutes”), and other formats are not recognized.


3. Tag Placeholders Referencing Non-Existent Properties

Section titled “3. Tag Placeholders Referencing Non-Existent Properties”

Wrong:

[Cacheable(Duration = "5m", Tags = ["user:{UserId}"])]
public partial class GetUserProfile
{
public required Guid Id { get; init; } // Property is "Id", not "UserId"
}

Compile result: PRAG1703 error — “Placeholder ‘{UserId}’ in tag/key refers to non-existent property on type ‘GetUserProfile’”.

Right:

[Cacheable(Duration = "5m", Tags = ["user:{Id}"])]
public partial class GetUserProfile
{
public required Guid Id { get; init; }
}

Or rename the property to match:

[Cacheable(Duration = "5m", Tags = ["user:{UserId}"])]
public partial class GetUserProfile
{
public required Guid UserId { get; init; }
}

Why: The source generator validates placeholder references at compile time. The placeholder name inside {...} must exactly match a public property name on the type. This is a compile-time safety check — without it, placeholders would silently expand to empty strings at runtime, causing cache keys and tags that do not differentiate entries correctly.


4. Excluding All Properties from the Cache Key

Section titled “4. Excluding All Properties from the Cache Key”

Wrong:

[Cacheable(Duration = "5m")]
public partial class GetGlobalConfig
{
[CacheKey(Exclude = true)]
public bool IncludeDebug { get; init; }
[CacheKey(Exclude = true)]
public string? Format { get; init; }
}

Compile result: PRAG1751 warning — “All properties on type ‘GetGlobalConfig’ are excluded from cache key generation. This may cause cache collisions.” The code compiles, but the generated key is just "GetGlobalConfig" with no property discrimination — every call returns the same cached result regardless of IncludeDebug or Format.

Right:

If the type truly has no differentiating parameters, remove the [CacheKey(Exclude = true)] attributes:

[Cacheable(Duration = "5m")]
public partial class GetGlobalConfig
{
// No properties = key is "GetGlobalConfig" — intentional singleton cache
}

If IncludeDebug and Format should affect the cache key, keep them included:

[Cacheable(Duration = "5m")]
public partial class GetGlobalConfig
{
public bool IncludeDebug { get; init; }
public string? Format { get; init; }
}
// Key: "GetGlobalConfig:IncludeDebug=True:Format=json"

Why: A cache key with no discriminating properties means every caller gets the same cached result. This is correct for a true singleton query (e.g., global configuration) but dangerous when different input combinations should produce different results. PRAG1751 warns you to confirm the intent.


5. Forgetting to Register HybridCache Before AddPragmaticCaching

Section titled “5. Forgetting to Register HybridCache Before AddPragmaticCaching”

Wrong:

// Missing: builder.Services.AddHybridCache()
builder.Services.AddPragmaticCaching();

Runtime result: InvalidOperationException when resolving ICacheStack, because HybridCacheStack requires HybridCache from DI. The constructor throws because the HybridCache dependency cannot be satisfied.

Right:

builder.Services.AddHybridCache(); // Required: registers HybridCache
builder.Services.AddPragmaticCaching(); // Registers ICacheStack -> HybridCacheStack

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

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

Why: AddPragmaticCaching registers ICacheStack as HybridCacheStack, which wraps Microsoft.Extensions.Caching.Hybrid.HybridCache. If HybridCache is not registered, the DI container cannot construct HybridCacheStack. AddHybridCache() is always required, even if you only use in-memory caching (L1).


6. Caching User-Specific Data Without Key Differentiation

Section titled “6. Caching User-Specific Data Without Key Differentiation”

Wrong:

[Cacheable(Duration = "5m")]
public partial class GetMyOrders
{
// UserId comes from ICurrentUser at runtime, not a property
}

The generated key is "GetMyOrders" — the same for all users. User A’s orders are cached, and User B sees them.

Right:

[Cacheable(Duration = "5m", Tags = ["orders:user:{UserId}"])]
public partial class GetMyOrders
{
public required Guid UserId { get; init; } // Set from ICurrentUser before caching
}
// Key: "GetMyOrders:UserId=abc-123" — unique per user

Or if you want the user ID as part of the key without a tag:

[Cacheable(Duration = "5m")]
public partial class GetMyOrders
{
[CacheKey(Order = 0)]
public required Guid UserId { get; init; }
}

Why: The source generator builds the cache key from public properties declared on the type. If the differentiating value (user ID, tenant ID) is not a property on the cacheable type, it is not included in the key. Always include any value that changes the result as a property on the cacheable type.


7. Broadcasting Invalidation When Targeted Would Suffice

Section titled “7. Broadcasting Invalidation When Targeted Would Suffice”

Wrong:

[InvalidatesCache("user:{UserId}")]
public partial class UserRolesChanged
{
public Guid UserId { get; init; }
}

This broadcasts the invalidation to ALL registered cache categories. If you have OutputCache, Permissions, and RateLimiting categories registered, all three receive the invalidation call — even though only the Permissions cache is affected by a role change.

Right:

[InvalidatesCache("user:{UserId}", Category = typeof(CacheCategories.Permissions))]
public partial class UserRolesChanged
{
public Guid UserId { get; init; }
}

Why: Broadcast invalidation (the default when Category is null) calls InvalidateByTagAsync on every registered category’s ICacheStack. This is correct when the event affects multiple subsystems (e.g., a user deletion should clear both permission cache and output cache). But when only one subsystem is affected, targeted invalidation avoids unnecessary work and reduces the risk of evicting unrelated entries that happen to share a tag pattern.


8. Using Sliding Expiration for Write-Heavy Data

Section titled “8. Using Sliding Expiration for Write-Heavy Data”

Wrong:

[Cacheable(Duration = "30m", Sliding = true)]
public partial class GetProductInventory
{
public required int ProductId { get; init; }
}

The inventory changes frequently (every purchase), but the sliding window resets on every read. If the product page is popular, the cache entry is never evicted by time — it only goes away when explicitly invalidated. During the gap between the write and the invalidation event, stale inventory counts are served.

Right:

[Cacheable(Duration = "30s", Tags = ["inventory:{ProductId}"])]
public partial class GetProductInventory
{
public required int ProductId { get; init; }
}
[InvalidatesCache("inventory:{ProductId}")]
public partial class InventoryChanged
{
public int ProductId { get; init; }
}

Why: Sliding expiration is designed for data that is read often and changes rarely — like user preferences or system configuration. For write-heavy data, use a short absolute duration combined with tag-based invalidation. This ensures that even if the invalidation event is delayed or missed, the stale data expires quickly.


Wrong:

[Cacheable(Duration = "5m")]
public partial class SearchProducts
{
[CacheKey(Order = 0)]
public required int TenantId { get; init; }
[CacheKey(Order = 0)] // Same Order as TenantId!
public required string Category { get; init; }
public int Page { get; init; } = 1;
}

Compile result: PRAG1750 warning — “Properties ‘TenantId’ and ‘Category’ have the same Order value 0. Order may be non-deterministic.” The key might be "SearchProducts:TenantId=1:Category=shoes:Page=1" or "SearchProducts:Category=shoes:TenantId=1:Page=1" depending on compiler internals. Different builds could produce different key orderings, causing cache misses after deployment.

Right:

[Cacheable(Duration = "5m")]
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;
}
// Key: "SearchProducts:TenantId=1:Category=shoes:Page=1" — deterministic

Why: When two properties share the same Order value, their position in the cache key is non-deterministic. This can cause cache misses across builds or even across different compilation targets. Assign unique Order values to ensure deterministic key ordering. Properties without explicit Order use int.MaxValue and sort by declaration order.


10. Not Using Categories for Subsystem Isolation

Section titled “10. Not Using Categories for Subsystem Isolation”

Wrong:

// Permission cache
[Cacheable(Duration = "5m", Tags = ["perms:{UserId}"])]
public partial class GetUserPermissions
{
public required Guid UserId { get; init; }
}
// Output cache for product pages
[Cacheable(Duration = "1m", Tags = ["product:{ProductId}"])]
public partial class GetProductPage
{
public required int ProductId { get; init; }
}

Both share the same default ICacheStack. A global KeyPrefix in CachingOptions applies to both. There is no way to set different default TTLs for permissions vs. product pages without specifying Duration on every single attribute.

Right:

[Cacheable(Duration = "5m", Category = typeof(CacheCategories.Permissions), Tags = ["user:{UserId}"])]
public partial class GetUserPermissions
{
public required Guid UserId { get; init; }
}
[Cacheable(Duration = "1m", Category = typeof(CacheCategories.OutputCache), Tags = ["product:{ProductId}"])]
public partial class GetProductPage
{
public required int ProductId { get; init; }
}

With registration:

builder.Services.AddPragmaticCaching(cache =>
{
cache.ForCategory<CacheCategories.Permissions>(o =>
{
o.KeyPrefix = "perms:";
o.DefaultDuration = TimeSpan.FromMinutes(5);
});
cache.ForCategory<CacheCategories.OutputCache>(o =>
{
o.KeyPrefix = "oc:";
o.DefaultDuration = TimeSpan.FromMinutes(1);
});
});

Why: Without categories, all cache entries share a single namespace. Category routing provides three benefits: (1) key prefix isolation prevents collisions, (2) per-category default durations reduce attribute verbosity, and (3) targeted invalidation avoids cross-subsystem eviction noise. Use categories whenever two subsystems have different caching requirements.


11. Manually Building Cache Keys Instead of Using Generated Helpers

Section titled “11. Manually Building Cache Keys Instead of Using Generated Helpers”

Wrong:

// In an invalidation handler:
var key = $"GetProductById:ProductId={evt.ProductId}";
await cache.RemoveAsync(key, ct);

This duplicates the key format from the [Cacheable] class. If the property name changes or the key format evolves, this string falls out of sync.

Right:

// Use the generated static helper
var key = GetProductByIdCacheKeys.Create(evt.ProductId);
await cache.RemoveAsync(key, ct);

Or use tag-based invalidation instead of key-based removal:

[InvalidatesCache("product:{ProductId}")]
public partial class ProductUpdated
{
public int ProductId { get; init; }
}

Why: The source generator produces a {Type}CacheKeys static helper class with a Create(...) method that builds the cache key using the same format as GetCacheKey(). Using this helper ensures the key format stays in sync. Even better, prefer tag-based invalidation via [InvalidatesCache] — it eliminates the need to know individual key formats entirely.


MistakeDiagnostic / Symptom
Missing partialPRAG1700 compile error
Invalid duration formatPRAG1701 compile error
Placeholder references non-existent propertyPRAG1703 compile error
All properties excluded from keyPRAG1751 warning, potential cache collisions
Missing AddHybridCache()InvalidOperationException at runtime
No user-differentiating propertyAll users share the same cached result
Broadcast invalidation when targeted sufficesUnnecessary evictions in unrelated categories
Sliding expiration on write-heavy dataStale data served indefinitely
Duplicate [CacheKey(Order)] valuesPRAG1750 warning, non-deterministic key ordering
No category isolationKey collisions, configuration bleed, invalidation noise
Manual key constructionKey format drift when properties change