Caching
Foundatio.Repositories provides built-in distributed caching with automatic invalidation. This guide covers cache configuration, behavior, and important gaps to be aware of.
Overview
Caching in Foundatio.Repositories is built on Foundatio's ICacheClient abstraction, supporting:
- In-memory caching (development/testing)
- Redis (distributed)
- Hybrid (in-memory L1 + distributed L2 for reduced network round trips)
- Any custom
ICacheClientimplementation
Configuration
Enable Caching
Provide an ICacheClient to your Elasticsearch configuration:
using Foundatio.Caching;
public class MyElasticConfiguration : ElasticConfiguration
{
public MyElasticConfiguration(ICacheClient cache, ILoggerFactory loggerFactory)
: base(cache: cache, loggerFactory: loggerFactory)
{
AddIndex(Employees = new EmployeeIndex(this));
}
public EmployeeIndex Employees { get; }
}Cache Implementations
// In-memory (for development/testing)
services.AddSingleton<ICacheClient>(new InMemoryCacheClient());
// Redis
services.AddSingleton<ICacheClient>(sp =>
new RedisCacheClient(o => o.ConnectionMultiplexer(sp.GetRequiredService<ConnectionMultiplexer>())));
// Hybrid (in-memory L1 + distributed L2 - best for production, reduces network round trips)
services.AddSingleton<ICacheClient>(sp =>
new RedisHybridCacheClient(o => o.ConnectionMultiplexer(sp.GetRequiredService<ConnectionMultiplexer>())));Repository Cache Settings
public class EmployeeRepository : ElasticRepositoryBase<Employee>
{
public EmployeeRepository(EmployeeIndex index) : base(index)
{
// Default cache expiration (default: 5 minutes)
DefaultCacheExpiration = TimeSpan.FromMinutes(10);
}
}Override Cache Client Per Repository
You can override the cache client for a specific repository by calling SetCacheClient in the constructor. This is useful when you need a different cache implementation (e.g., a hybrid cache) for a particular repository:
public class MyRepository : ElasticRepositoryBase<MyDocument>
{
public MyRepository(
MyElasticConfiguration elasticConfig,
[FromKeyedServices("hybrid")] ICacheClient cacheClient
) : base(elasticConfig.MyIndex)
{
SetCacheClient(cacheClient);
}
}You can also disable caching entirely for a repository:
public class UncachedRepository : ElasticRepositoryBase<MyDocument>
{
public UncachedRepository(MyElasticConfiguration elasticConfig)
: base(elasticConfig.MyIndex)
{
DisableCache();
}
}Using the Cache
Cache on Read
// Cache by document ID
var employee = await repository.GetByIdAsync(id, o => o.Cache());
// Cache with custom key
var hit = await repository.FindOneAsync(
q => q.FieldEquals(e => e.Email, email),
o => o.Cache($"employee:email:{email}"));
var employee = hit?.Document;
// Cache with custom expiration
var employee = await repository.GetByIdAsync(id,
o => o.Cache().CacheExpiresIn(TimeSpan.FromMinutes(30)));Cache Options
// Enable caching
o.Cache()
// Enable with specific key
o.Cache("my-cache-key")
// Enable with key and expiration
o.Cache("my-key", TimeSpan.FromMinutes(5))
// Set cache key separately
o.CacheKey("my-key")
// Set expiration
o.CacheExpiresIn(TimeSpan.FromMinutes(10))
o.CacheExpiresAt(DateTime.UtcNow.AddHours(1))
// Read from cache only (don't write)
o.ReadCache()
// Disable caching for this operation
o.Cache(false)Automatic Cache Invalidation
The repository automatically invalidates cache in most scenarios.
When Cache IS Automatically Invalidated
| Operation | Behavior |
|---|---|
AddAsync | Documents added to cache after successful add |
SaveAsync | Cache invalidated by ID, then documents re-added |
RemoveAsync | Cache invalidated by document ID |
PatchAsync (single ID) | Cache invalidated by ID |
PatchAsync (multiple IDs) | Cache invalidated for all IDs |
Code Flow for Save
// When you call SaveAsync:
await repository.SaveAsync(employee);
// Internally:
// 1. Document is indexed to Elasticsearch
// 2. Cache is invalidated for the document ID
// 3. Document is added back to cache with updated values
// 4. EntityChanged notification is publishedCache Behavior on Partial Failure
When a bulk write operation partially fails (some documents succeed, others fail), the cache is updated only for successful documents:
| Document Status | Cache Action |
|---|---|
| Succeeded | Added to cache (freshest data available) |
| Failed (any error) | Cache entry unchanged (failed writes don't mutate Elasticsearch) |
Failed writes (409 conflicts, 429/503 rate limits, or other errors) do not mutate the document in Elasticsearch, so the existing cache entry (if any) remains valid. Cache consistency for concurrent writes is handled by the message bus EntityChanged notifications — the writer that successfully mutated the document is responsible for updating or invalidating the cache.
try
{
await repository.SaveAsync(documents, o => o.Cache());
}
catch (VersionConflictDocumentException)
{
// Successful docs: cached with latest data
// Conflicting docs: cache unchanged (the successful concurrent writer handles its own cache update)
}Cache Invalidation Gaps
Important
There are scenarios where cache is NOT automatically invalidated. Understanding these gaps is critical for maintaining cache consistency.
PatchAllAsync - Partial Gap
When using PatchAllAsync, only document IDs are invalidated. Custom cache keys are NOT invalidated.
// This will invalidate cache by document IDs
await repository.PatchAllAsync(
q => q.FieldEquals(e => e.Department, "Engineering"),
new ScriptPatch("ctx._source.salary += 1000"));
// But if you cached by a custom key like "dept:engineering:employees",
// that cache key is NOT invalidatedSolution: Override InvalidateCacheByQueryAsync or manually invalidate:
// After PatchAllAsync, manually invalidate custom keys
await repository.InvalidateCacheAsync("dept:engineering:employees");RemoveAllAsync (Query) - Conditional Gap
When RemoveAllAsync uses DeleteByQuery (no event listeners and cache disabled), only query IDs are invalidated:
// If no event listeners are registered and cache is disabled,
// this uses DeleteByQuery which only invalidates query IDs
await repository.RemoveAllAsync(q => q.FieldEquals(e => e.Status, "inactive"));Solution: Ensure cache is enabled or add event listeners:
// Enable cache for the operation
await repository.RemoveAllAsync(query, o => o.Cache());Direct Elasticsearch Operations
Any operations performed directly via the Elasticsearch client bypass the repository entirely:
// This bypasses the repository - NO cache invalidation
await _elasticClient.IndexAsync(document, i => i.Index("employees"));Solution: Always use repository methods for data operations.
Manual Cache Invalidation
Invalidate by Document
// Single document
await repository.InvalidateCacheAsync(employee);
// Multiple documents
await repository.InvalidateCacheAsync(employees);Invalidate by Cache Key
// Single key
await repository.InvalidateCacheAsync("my-cache-key");
// Multiple keys
await repository.InvalidateCacheAsync(new[] { "key1", "key2", "key3" });Custom Cache Key Invalidation Pattern
Override InvalidateCacheAsync to handle custom cache keys:
public class EmployeeRepository : ElasticRepositoryBase<Employee>
{
// Override to invalidate email cache when documents change
protected override async Task InvalidateCacheAsync(
IReadOnlyCollection<ModifiedDocument<Employee>> documents,
ChangeType? changeType = null)
{
// Call base implementation for ID-based invalidation
await base.InvalidateCacheAsync(documents, changeType);
// Invalidate custom cache keys for current email addresses
var emailKeys = documents
.Where(d => !string.IsNullOrEmpty(d.Value.Email))
.Select(d => $"employee:email:{d.Value.Email.ToLowerInvariant()}")
.ToList();
if (emailKeys.Count > 0)
await Cache.RemoveAllAsync(emailKeys);
// Also invalidate original email if it changed
var originalEmailKeys = documents
.Where(d => d.Original != null && !string.IsNullOrEmpty(d.Original.Email))
.Where(d => d.Original.Email != d.Value.Email)
.Select(d => $"employee:email:{d.Original.Email.ToLowerInvariant()}")
.ToList();
if (originalEmailKeys.Count > 0)
await Cache.RemoveAllAsync(originalEmailKeys);
}
}Soft Delete Cache Behavior
For entities implementing ISupportSoftDeletes, the repository maintains a special cache list to handle eventual consistency.
How It Works
When a document is soft-deleted:
employee.IsDeleted = true;
await repository.SaveAsync(employee);
// Internally:
// 1. Document ID is added to "deleted" list in cache
// 2. List has 30-second TTL
// 3. Queries automatically exclude IDs in the "deleted" listQuery Filtering
Before queries execute, soft-deleted IDs are excluded:
// When you query:
var results = await repository.FindAsync(query);
// Internally (if SoftDeleteQueryMode.ActiveOnly):
// 1. Check cache for "deleted" list
// 2. Add excluded IDs to query
// 3. Execute queryPurpose
This handles the eventual consistency window where:
- Document is soft-deleted
- Elasticsearch hasn't indexed the change yet
- Cache knows about the deletion
- Queries correctly exclude the document
After 30 seconds, Elasticsearch should have indexed the change, and the cache entry expires.
Distributed Cache Consistency
Message Bus Integration
For distributed scenarios, subscribe to EntityChanged messages:
await messageBus.SubscribeAsync<EntityChanged>(async (msg, ct) =>
{
if (msg.Type == nameof(Employee))
{
// Invalidate local cache when other instances make changes
await repository.InvalidateCacheAsync(msg.Id);
}
});NotificationDeliveryDelay
Use NotificationDeliveryDelay to allow Elasticsearch indexing to complete before consumers read:
public class EmployeeRepository : ElasticRepositoryBase<Employee>
{
public EmployeeRepository(EmployeeIndex index) : base(index)
{
NotificationDeliveryDelay = TimeSpan.FromSeconds(1);
}
}Cache Best Practices
1. Use Consistent Cache Keys
// Good: Consistent key format
o.Cache($"employee:email:{email.ToLowerInvariant()}")
// Bad: Inconsistent casing
o.Cache($"employee:email:{email}") // May not match invalidation2. Override InvalidateCacheAsync for Custom Keys
protected override async Task InvalidateCacheAsync(
IReadOnlyCollection<ModifiedDocument<Employee>> documents,
ChangeType? changeType = null)
{
await base.InvalidateCacheAsync(documents, changeType);
// Add custom key invalidation
var customKeys = documents.Select(d => $"custom:{d.Value.CustomField}");
await Cache.RemoveAllAsync(customKeys);
}3. Be Aware of PatchAllAsync Gaps
// After bulk operations, manually invalidate if needed
await repository.PatchAllAsync(query, patch);
await repository.InvalidateCacheAsync("affected-cache-key");4. Use Repository Methods
// Good: Uses repository, cache is managed
await repository.SaveAsync(employee);
// Bad: Bypasses repository, cache not invalidated
await _elasticClient.IndexAsync(employee);5. Test Cache Behavior
// Use InMemoryCacheClient for testing
var cache = new InMemoryCacheClient();
// ... perform operations ...
// Check cache statistics
var stats = cache.GetStats();
Console.WriteLine($"Hits: {stats.Hits}, Misses: {stats.Misses}");Summary: Cache Invalidation Matrix
| Operation | Auto-Invalidated | Custom Keys | Notes |
|---|---|---|---|
AddAsync | Yes (adds to cache) | No | Override InvalidateCacheAsync |
SaveAsync | Yes | No | Override InvalidateCacheAsync |
RemoveAsync | Yes | No | Override InvalidateCacheAsync |
PatchAsync (ID) | Yes | No | Override InvalidateCacheAsync |
PatchAllAsync | Partial (IDs only) | No | Manual invalidation needed |
RemoveAllAsync | Partial | No | Use o.Cache() or add listeners |
| Direct ES | No | No | Always use repository |
Advanced: Real-Time Reads vs Eventual Consistency
Understanding Elasticsearch Segments
Elasticsearch uses a near real-time search model. When you index a document, it's not immediately searchable—it must first be written to a segment and refreshed (default: every 1 second).
However, Get by ID operations are real-time. They read directly from the transaction log, bypassing the segment refresh cycle.
Operations That Are Real-Time
| Operation | Real-Time? | Notes |
|---|---|---|
GetByIdAsync | ✅ Yes | Uses Multi-Get API, reads from transaction log |
GetByIdsAsync | ✅ Yes | Uses Multi-Get API |
ExistsAsync(id) | ✅ Yes | Uses Document Exists API (unless soft deletes) |
FindAsync | ❌ No | Uses Search API, subject to refresh interval |
FindOneAsync | ❌ No | Uses Search API |
CountAsync | ❌ No | Uses Count/Search API |
The Dirty Read Problem
When using FindAsync or FindOneAsync, you may get stale results during the refresh window:
// Add a new employee
var employee = await repository.AddAsync(new Employee { Email = "john@example.com" });
// This might NOT find the employee (dirty read)
var found = await repository.FindOneAsync(q => q.FieldEquals(e => e.Email, "john@example.com"));
// found could be null!
// But this WILL find it (real-time)
var byId = await repository.GetByIdAsync(employee.Id);
// byId is guaranteed to existSolving Dirty Reads with Caching
The repository handles this by not caching dirty reads by ID:
// Internal behavior in AddDocumentsToCacheAsync:
protected virtual async Task AddDocumentsToCacheAsync(
ICollection<FindHit<T>> findHits,
ICommandOptions options,
bool isDirtyRead)
{
// Custom cache keys are always cached (you explicitly requested it)
if (options.HasCacheKey())
{
await Cache.SetAsync(options.GetCacheKey(), findHits, options.GetExpiresIn());
// ...
}
// Don't add dirty read documents by ID - they may be out of sync
if (isDirtyRead)
return;
// Only cache by ID for real-time reads
// ...
}Pattern: Custom Cache Keys for Eventual Consistency
You can use custom cache keys to make FindOneAsync work reliably without requiring immediate consistency:
public class UserRepository : ElasticRepositoryBase<User>
{
public async Task<User?> GetByEmailAddressAsync(string emailAddress)
{
if (String.IsNullOrWhiteSpace(emailAddress))
return null;
emailAddress = emailAddress.Trim().ToLowerInvariant();
// Use a custom cache key - this caches the result even for dirty reads
var hit = await FindOneAsync(
q => q.FieldEquals(u => u.EmailAddress, emailAddress),
o => o.Cache(EmailCacheKey(emailAddress)));
return hit?.Document;
}
// Override to add documents to cache by email
protected override async Task AddDocumentsToCacheAsync(
ICollection<FindHit<User>> findHits,
ICommandOptions options,
bool isDirtyRead)
{
await base.AddDocumentsToCacheAsync(findHits, options, isDirtyRead);
// Cache by email address for future lookups
var cacheEntries = new Dictionary<string, FindHit<User>>();
foreach (var hit in findHits.Where(d => !String.IsNullOrEmpty(d.Document?.EmailAddress)))
cacheEntries.Add(EmailCacheKey(hit.Document.EmailAddress), hit);
if (cacheEntries.Count > 0)
await AddDocumentsToCacheWithKeyAsync(cacheEntries, options.GetExpiresIn());
}
// Override to invalidate email cache when documents change
protected override Task InvalidateCacheAsync(
IReadOnlyCollection<ModifiedDocument<User>> documents,
ChangeType? changeType = null)
{
// Union originals and modified values to handle field renames
var keysToRemove = documents.UnionOriginalAndModified()
.Where(u => !string.IsNullOrEmpty(u.EmailAddress))
.Select(u => EmailCacheKey(u.EmailAddress))
.Distinct();
return Task.WhenAll(
Cache.RemoveAllAsync(keysToRemove),
base.InvalidateCacheAsync(documents, changeType));
}
private static string EmailCacheKey(string emailAddress)
=> String.Concat("Email:", emailAddress.Trim().ToLowerInvariant());
}How this works:
- First call:
FindOneAsyncsearches Elasticsearch (may be a dirty read), caches result by email key - Subsequent calls: Returns cached result immediately, no Elasticsearch query needed
- On save/update:
InvalidateCacheAsyncclears the email cache key - Next lookup: Fresh search, re-cached
This pattern provides eventual consistency with caching - you don't need Consistency.Immediate because:
- The cache is populated on first successful lookup
- The cache is invalidated when the document changes
- Subsequent lookups hit the cache, not Elasticsearch
Advanced: Originals for Change Detection
What Are Originals?
When OriginalsEnabled = true, the repository fetches the current document from the database before saving. This allows you to:
- Detect what fields changed
- Access the previous values in event handlers
- Properly invalidate cache for changed values (like email addresses)
Enabling Originals
public class UserRepository : ElasticRepositoryBase<User>
{
public UserRepository(UserIndex index) : base(index)
{
OriginalsEnabled = true; // Fetch original before save
}
}Using Originals in Event Handlers
DocumentsChanging.AddHandler(async (sender, args) =>
{
foreach (var doc in args.Documents)
{
if (doc.Original != null)
{
// Compare old and new values
if (doc.Original.Email != doc.Value.Email)
{
_logger.LogInformation(
"Email changed from {Old} to {New}",
doc.Original.Email,
doc.Value.Email);
}
}
}
});UnionOriginalAndModified Pattern
A common pattern for cache invalidation is to collect keys from both the original and modified documents (to handle field changes like email address renames). The built-in UnionOriginalAndModified extension method (in Foundatio.Repositories.Extensions) combines both the current and previous versions of each document:
protected override Task InvalidateCacheAsync(
IReadOnlyCollection<ModifiedDocument<User>> documents,
ChangeType? changeType = null)
{
var keysToRemove = documents.UnionOriginalAndModified()
.Where(u => !string.IsNullOrEmpty(u.EmailAddress))
.Select(u => EmailCacheKey(u.EmailAddress))
.Distinct();
return Task.WhenAll(
Cache.RemoveAllAsync(keysToRemove),
base.InvalidateCacheAsync(documents, changeType));
}Per-Operation Control
// Enable originals for a specific operation
await repository.SaveAsync(user, o => o.Originals(true));
// Disable originals for a specific operation (performance optimization)
await repository.SaveAsync(user, o => o.Originals(false));
// Pass known originals to avoid extra database fetch
await repository.SaveAsync(user, o => o.AddOriginals(originalUser));Performance Consideration
Enabling OriginalsEnabled adds an extra database read before each save. Use it when you need:
- Change detection in event handlers
- Proper cache invalidation for non-ID fields
- Soft delete notifications (to know the document was active before deletion)
Advanced: Required Fields for Cache Invalidation
The Problem
When callers request partial documents (via .Include(), .Exclude(), or field masks), fields needed for cache invalidation or event handling may be missing. For example, if you cache by EmailAddress and a caller requests only Id and Name, the InvalidateCacheAsync override won't have the email to clear the cache.
AddRequiredField
Register fields that must always be returned when any caller-specified source filtering is active:
public class UserRepository : ElasticRepositoryBase<User>
{
public UserRepository(UserIndex index) : base(index)
{
AddRequiredField(u => u.EmailAddress, u => u.OrganizationIds);
}
}Required fields are automatically injected when caller-specified source filtering is active: added to the include set when includes are present, or removed from the exclude set when only excludes are present. This applies to all source-filtered operations: GetByIdAsync, GetByIdsAsync, FindAsync, RemoveAllAsync, and PatchAllAsync.
See Required Fields for full details on injection behavior, precedence rules, and interactions with default excludes.
Default Required Fields
The repository automatically registers these as required fields:
Id(always required)CreatedUtc(if entity implementsIHaveCreatedDate)
Use Cases
- Cache invalidation by non-ID fields: Need the email to invalidate email cache
- Event handlers that need specific data: Audit logging, notifications
- Cascade operations: Need organization IDs to update related entities
- Multi-tenancy: Authorization checks that need tenant identifiers
Next Steps
- Message Bus - Distributed cache invalidation
- Configuration - Cache configuration options
- Soft Deletes - Soft delete cache behavior