Cache-aside decorator for RepletoryLib repositories with cross-service entity caching, pagination, and streaming
$ dotnet add package RepletoryLib.Caching.RepositoryCache-aside decorator for RepletoryLib repositories with write-through caching and cross-service entity sharing.
This library provides transparent caching for IRepository<T> and ICompositeKeyRepository<T> using the decorator pattern. It is designed for microservice architectures where:
| Operation | Strategy |
|---|---|
| Read | Cache first → return if hit. If miss → fetch from DB → cache → return |
| Write/Update | DB first → update cache → return |
| Delete | DB first → invalidate cache → return |
┌─────────────────────────┐ ┌─────────────────────────┐
│ UserManagement Service │ │ CourseEnrollment Service│
│ (OWNS User entity) │ │ (reads User from cache) │
│ │ │ │
│ IRepository<User> │ │ ICacheService │
│ └─ CachedRepository │ │ └─ GetAsync<User>(key)│
│ └─ Repository │ │ │
│ └─ Database │ │ No User DI registration │
└────────┬────────────────┘ └────────┬────────────────┘
│ write-through │ read-only
└──────────┬─────────────────────┘
▼
┌──────────┐
│ Redis │
│ (shared)│
└──────────┘
<PackageReference Include="RepletoryLib.Caching.Repository" Version="1.0.0" />
// SharedLib.CacheKeys/UserCacheKeys.cs
// ICacheKeyProvider<T> implementation — used by CachedRepository in the owning service
public class UserCacheKeyProvider : ICacheKeyProvider<UserEntity>
{
public string EntityPrefix => "user";
public string GetEntityKey(Guid id) => $"user:{id}";
public string GetIndexKey() => "user:ids";
public TimeSpan? Expiry => TimeSpan.FromMinutes(30);
}
// Static helpers — used by consuming services (zero DI registration)
public static class UserCacheKeys
{
public const string Prefix = "user";
public static string ById(Guid id) => $"user:{id}";
public const string IndexKey = "user:ids";
}
// UserManagement service — owns UserEntity, RoleEntity, etc.
services.AddRepletoryRepositories<AppDbContext>(configuration); // auto-register ALL repos
services.AddRepletryCacheKeyProviders(typeof(UserCacheKeyProvider).Assembly); // scan key providers
services.AddRepletryCaching(configuration); // wrap matching repos
That's it — whether you have 5 or 50 entities, it's always 3 lines. Every DbSet<T> on AppDbContext gets an IRepository<T> (for BaseEntity types) or ICompositeKeyRepository<T>, and every entity with a matching ICacheKeyProvider<T> is automatically wrapped with CachedRepository<T>.
// CourseEnrollment service — only registers entities it OWNS
services.AddRepletoryRepositories<CourseDbContext>(configuration);
services.AddRepletryCacheKeyProviders(typeof(CourseCacheKeyProvider).Assembly);
services.AddRepletryCaching(configuration);
public class CourseEnrollmentService(
IRepository<CourseEnrollmentEntity> enrollmentRepo, // cached — owns this entity
ICacheService cache) // already registered
{
public async Task<CourseEnrollmentView> GetAsync(Guid id)
{
var enrollment = await enrollmentRepo.GetByIdAsync(id);
// Read User directly from shared cache using static key helper
var user = await cache.GetAsync<UserEntity>(UserCacheKeys.ById(enrollment!.UserId));
return new CourseEnrollmentView
{
EnrollmentId = enrollment.Id,
CourseName = enrollment.CourseName,
UserName = user?.FullName, // enriched from shared cache
UserEmail = user?.Email // no HTTP call to UserManagement
};
}
}
ICacheKeyProvider<T>)For entities inheriting from BaseEntity with a Guid primary key:
public interface ICacheKeyProvider<T> where T : BaseEntity
{
string EntityPrefix { get; } // e.g. "user"
string GetEntityKey(Guid id); // e.g. "user:{id}"
string GetIndexKey(); // e.g. "user:ids"
TimeSpan? Expiry { get; } // null = use DefaultExpiryMinutes from options
}
ICompositeKeyCacheKeyProvider<T>)For entities with composite primary keys:
public interface ICompositeKeyCacheKeyProvider<T> where T : class
{
string EntityPrefix { get; }
string GetEntityKey(object[] keyValues);
string GetIndexKey();
string SerializeKeyValues(object[] keyValues); // for index storage
object[] DeserializeKeyValues(string serialized); // for index retrieval
TimeSpan? Expiry { get; }
}
Example implementation:
public class OrderItemCacheKeyProvider : ICompositeKeyCacheKeyProvider<OrderItemEntity>
{
public string EntityPrefix => "order_item";
public string GetEntityKey(object[] keyValues) => $"order_item:{keyValues[0]}:{keyValues[1]}";
public string GetIndexKey() => "order_item:ids";
public string SerializeKeyValues(object[] keyValues) => $"{keyValues[0]}:{keyValues[1]}";
public object[] DeserializeKeyValues(string serialized)
{
var parts = serialized.Split(':');
return [Guid.Parse(parts[0]), Guid.Parse(parts[1])];
}
public TimeSpan? Expiry => TimeSpan.FromMinutes(15);
}
Each cached entity type maintains an ID index — a single cache key storing all entity IDs for that type:
Cache Key: "user:ids"
Value: [guid1, guid2, guid3, ...]
Cache Key: "user:{guid1}"
Value: { Id: guid1, FullName: "Alice", Email: "alice@example.com", ... }
Cache Key: "user:{guid2}"
Value: { Id: guid2, FullName: "Bob", Email: "bob@example.com", ... }
The index enables GetAllAsync, FindAsync, pagination, and streaming without hitting the database.
These methods are available to any service that has ICacheService registered — no per-entity registration needed:
var users = await cache.GetAllFromIndexAsync<UserEntity>(
UserCacheKeys.IndexKey, UserCacheKeys.ById);
var activeUsers = await cache.FindFromIndexAsync<UserEntity>(
UserCacheKeys.IndexKey, UserCacheKeys.ById,
u => u.IsActive);
var page = await cache.GetPagedFromIndexAsync<UserEntity>(
UserCacheKeys.IndexKey, UserCacheKeys.ById,
page: 2, pageSize: 20);
// page.Items — entities on this page
// page.TotalCount — total entities in index
// page.TotalPages — computed total pages
// page.HasNextPage — true if more pages after this one
// page.HasPreviousPage — true if pages before this one
var activePage = await cache.FindPagedFromIndexAsync<UserEntity>(
UserCacheKeys.IndexKey, UserCacheKeys.ById,
u => u.IsActive,
page: 1, pageSize: 50);
await foreach (var batch in cache.StreamFromIndexAsync<UserEntity>(
UserCacheKeys.IndexKey, UserCacheKeys.ById, batchSize: 100))
{
foreach (var user in batch.Items)
ProcessUser(user);
Console.WriteLine(
$"Batch {batch.BatchNumber}/{batch.TotalBatches}, " +
$"processed {batch.ProcessedCount}/{batch.TotalCount}, " +
$"has more: {batch.HasMore}");
}
var exists = await cache.EntityExistsAsync(UserCacheKeys.ById(userId));
All the above methods have composite-key equivalents:
// Get all composite-keyed entities
var items = await cache.GetAllFromCompositeIndexAsync<OrderItemEntity>(
OrderItemCacheKeys.IndexKey, OrderItemCacheKeys.BySerializedKey);
// Paginated
var page = await cache.GetPagedFromCompositeIndexAsync<OrderItemEntity>(
OrderItemCacheKeys.IndexKey, OrderItemCacheKeys.BySerializedKey,
page: 1, pageSize: 50);
// Stream
await foreach (var batch in cache.StreamFromCompositeIndexAsync<OrderItemEntity>(
OrderItemCacheKeys.IndexKey, OrderItemCacheKeys.BySerializedKey,
batchSize: 100))
{
// process batch.Items
}
The CachedRepository<T> decorator wraps IRepository<T> transparently:
| Method | Strategy |
|---|---|
GetByIdAsync | Cache first → miss: DB → cache → add to index |
GetAllAsync | Read index → resolve each from cache → batch-fetch misses from DB → cache misses. Empty index triggers full rebuild |
FindAsync | If index ≤ max: resolve all + in-memory filter. Else: fallback to DB |
AddAsync | DB → cache entity → add ID to index |
AddRangeAsync | DB → cache each → add IDs to index |
UpdateAsync | DB → overwrite in cache |
UpdateRangeAsync | DB → overwrite each in cache |
SoftDeleteAsync | DB → remove from cache → remove from index |
HardDeleteAsync | DB → remove from cache → remove from index |
SoftDeleteRangeAsync | DB → remove each from cache & index |
HardDeleteRangeAsync | DB → remove each from cache & index |
ExistsAsync | Pass through to DB (lightweight query) |
CountAsync | Pass through to DB (lightweight query) |
Query() | Pass through to inner (IQueryable, not cacheable) |
When IDistributedLockService is registered (e.g., via RepletoryLib.Caching.Redis), index mutations are protected with a distributed lock to prevent race conditions across multiple instances:
AddToIndex: lock("lock:user:ids") → read index → add ID → write → release
RemoveFromIndex: lock("lock:user:ids") → read index → remove ID → write → release
When IDistributedLockService is not registered, index updates use simple read-modify-write without locking.
{
"Caching": {
"Repository": {
"DefaultExpiryMinutes": 60,
"EnableIndexKey": true,
"MaxEntitiesForInMemoryFilter": 10000,
"FallbackToDbForFind": true
}
}
}
| Property | Type | Default | Description |
|---|---|---|---|
DefaultExpiryMinutes | int | 60 | Fallback TTL when ICacheKeyProvider.Expiry is null |
EnableIndexKey | bool | true | Maintain ID index for GetAll/Find resolution |
MaxEntitiesForInMemoryFilter | int | 10000 | FindAsync safety limit before falling back to DB |
FallbackToDbForFind | bool | true | If index count exceeds max, FindAsync hits DB instead |
// 1. Auto-register all repositories from DbContext
services.AddRepletoryRepositories<AppDbContext>(configuration);
// 2. Scan assembly for ICacheKeyProvider<T> implementations
services.AddRepletryCacheKeyProviders(typeof(UserCacheKeyProvider).Assembly);
// 3. Auto-wrap all matching repos with cached decorators
services.AddRepletryCaching(configuration);
// Register a specific entity's cached repository
services.AddRepletryCachedRepository<UserEntity>(configuration);
// Register a composite-key entity's cached repository
services.AddRepletryCachedCompositeKeyRepository<OrderItemEntity>(configuration);
public class PagedCacheResult<T>
{
public IReadOnlyList<T> Items { get; set; }
public int TotalCount { get; set; }
public int Page { get; set; }
public int PageSize { get; set; }
public int TotalPages { get; } // computed
public bool HasNextPage { get; } // computed
public bool HasPreviousPage { get; } // computed
}
public class StreamBatch<T>
{
public IReadOnlyList<T> Items { get; set; }
public int BatchNumber { get; set; }
public int TotalBatches { get; set; }
public int TotalCount { get; set; }
public int ProcessedCount { get; set; } // cumulative items yielded so far
public bool HasMore { get; } // computed
}
| Package | Purpose |
|---|---|
RepletoryLib.Caching.Abstractions | ICacheService, IDistributedLockService |
RepletoryLib.Data.EntityFramework | IRepository<T>, ICompositeKeyRepository<T>, BaseEntity |
Microsoft.Extensions.DependencyInjection.Abstractions | DI container abstractions |
Microsoft.Extensions.Logging.Abstractions | Logging abstractions |
Microsoft.Extensions.Options.ConfigurationExtensions | Options pattern |
| Role | Registration | How to Read |
|---|---|---|
| Owning service | AddRepletoryRepositories + AddRepletryCacheKeyProviders + AddRepletryCaching | Inject IRepository<T> (transparently cached) |
| Consuming service | None (for entities it doesn't own) | Inject ICacheService + use static key helpers or extension methods |