در مقالهی کشینگ، گفتیم رایجترین الگو برای کش کردن داده Cache-Aside است: در ابتدا سراغ کش میرویم، اگر دادهای موجود نبود از دیتابیس میخوانیم و در کش میگذاریم. اما یک سؤال مهم باقی میماند: این منطق را کجا بنویسیم؟ داخل Service؟ داخل Repository؟ یا یک لایهی جدا؟
در این مقاله نشان میدهیم چرا بهترین جواب، استفاده از الگوی طراحی Decorator است؛ الگویی که به شما اجازه میدهد رفتار جدید (کشینگ) را به کلاس موجود اضافه کنید، بدون اینکه کلاس اصلی یا Interface آن تغییر کند. (اصل OCP در SOLID)
چرا نباید کشینگ را داخل Repository اصلی بنویسیم؟
فرض کنید ProductRepository داریم که از دیتابیس میخواند. راحتترین راه این است که مستقیم داخل همین کلاس، منطق Redis را هم اضافه کنیم. اما این کار چند مشکل جدی ایجاد میکند:
- نقض Single Responsibility: حالا این کلاس هم مسئول دسترسی به داده است، هم مسئول کشینگ
- تستپذیری پایین: برای تست منطق اصلی Repository، مجبورید Redis را هم Mock کنید.
- عدم انعطاف: اگر بخواهید کش را غیرفعال کنید یا نوع آن را عوض کنید، باید کد اصلی را تغییر دهید.
اصل راهنما: «هر تغییر باید در یک نقطه اتفاق بیفتد.» منطق دسترسی به داده و منطق کشینگ، دو دلیل متفاوت برای تغییر هستند؛ پس باید در دو کلاس متفاوت باشند.
الگوی Decorator چیست؟
الگوی Decorator یک الگوی طراحی ساختاری (Structural Pattern) است که به شما اجازه میدهد رفتار جدیدی را به یک object اضافه کنید، با Wrap کردن آن در یک کلاس دیگر که همان Interface را پیادهسازی میکند. از دید مصرفکننده (Consumer)، هیچ تفاوتی نیست و همان Interface صدا زده میشود، اما پشتصحنه رفتار اضافهای هم اجرا میشود.
در سناریوی ما: یک CachedProductRepository میسازیم که همان IProductRepository را پیادهسازی میکند، اما داخلش یک نمونه از Repository اصلی (بدون کش) را نگه میدارد و قبل از صدا زدن آن، ابتدا کش را چک میکند.
پیادهسازی گامبهگام
۱. Interface (لایهی Application)
این Interface هیچ اطلاعی از وجود کش ندارد — دقیقاً همانطور که باید باشد:
public interface IProductRepository
{
Task<Product?> GetByIdAsync(int id);
Task<IEnumerable<Product>> GetAllAsync();
Task UpdateAsync(Product product);
}
۲. پیادهسازی اصلی (لایهی Infrastructure)
پیادهسازی واقعی که مستقیم با Entity Framework Core کار میکند — بدون هیچ منطق کشینگ:
public class EfProductRepository : IProductRepository
{
private readonly AppDbContext _context;
public EfProductRepository(AppDbContext context)
{
_context = context;
}
public async Task<Product?> GetByIdAsync(int id)
=> await _context.Products.FindAsync(id);
public async Task<IEnumerable<Product>> GetAllAsync()
=> await _context.Products.AsNoTracking().ToListAsync();
public async Task UpdateAsync(Product product)
{
_context.Products.Update(product);
await _context.SaveChangesAsync();
}
}
۳. Decorator کشینگ (لایهی Infrastructure)
این کلاس، همان Interface را پیادهسازی میکند و یک نمونه از Repository اصلی را داخل خودش نگه میدارد (Composition، نه Inheritance). منطق Cache-Aside اینجا پیادهسازی میشود:
public class CachedProductRepository : IProductRepository
{
private readonly IProductRepository _inner;
private readonly IDistributedCache _cache;
private static readonly DistributedCacheEntryOptions CacheOptions = new()
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10),
SlidingExpiration = TimeSpan.FromMinutes(3)
};
public CachedProductRepository(IProductRepository inner, IDistributedCache cache)
{
_inner = inner;
_cache = cache;
}
public async Task<Product?> GetByIdAsync(int id)
{
var cacheKey = $"product:{id}";
var cached = await _cache.GetStringAsync(cacheKey);
if (cached is not null)
return JsonSerializer.Deserialize<Product>(cached);
var product = await _inner.GetByIdAsync(id);
if (product is not null)
{
await _cache.SetStringAsync(
cacheKey,
JsonSerializer.Serialize(product),
CacheOptions);
}
return product;
}
public Task<IEnumerable<Product>> GetAllAsync()
=> _inner.GetAllAsync(); // مثال: لیست کامل رو فعلاً کش نمیکنیم
public async Task UpdateAsync(Product product)
{
await _inner.UpdateAsync(product);
// ابطال کش پس از تغییر داده — بخش حیاتی Cache-Aside
await _cache.RemoveAsync($"product:{product.Id}");
}
}
نکتهی کلیدی: متد UpdateAsync بعد از نوشتن موفق در دیتابیس، بلافاصله کلید مرتبط را از کش پاک میکند. این دقیقاً همان بخشی است که اگر فراموش شود، باعث نمایش دادهی قدیمی به کاربر میشود.
۴. Registration در Program.cs
اینجاست که جادوی واقعی اتفاق میافتد — Repository اصلی و نسخهی کششده هر دو در DI Container ثبت میشوند، و کد مصرفکننده (مثل یک Application Service) فقط IProductRepository را میبیند:
builder.Services.AddStackExchangeRedisCache(options =>
{
options.Configuration = builder.Configuration.GetConnectionString("Redis");
});
// ثبت پیادهسازی اصلی با یک نام مشخص (Keyed Service در .NET 8+)
builder.Services.AddScoped<EfProductRepository>();
// ثبت Decorator بهعنوان پیادهسازی نهایی Interface
builder.Services.AddScoped<IProductRepository>(sp =>
new CachedProductRepository(
sp.GetRequiredService<EfProductRepository>(),
sp.GetRequiredService<IDistributedCache>()));
و حتی روشی بهتر استفاده از پکیج Scrutor
builder.Services.Decorate<IProductRepository, CachedProductRepository>();
از این نقطه به بعد، هر جای پروژه که IProductRepository را Inject کنید، در واقع نسخهی Decorator شده (با کش) را دریافت میکنید — بدون اینکه حتی یک خط در Application Service ها یا کنترلرها تغییر کند.
مزایای این رویکرد
یک نکتهی مهم دربارهی Consistency
در مثال بالا، Invalidation بهصورت Synchronous و همان لحظهی Update انجام شد؛ این سادهترین و امنترین حالت برای اغلب پروژههاست. اگر سیستم شما چند نمونه (Instance) دارد و از یک Redis مشترک استفاده میکند، همین رویکرد کافی است چون همه به یک کش مشترک وصل هستند. تنها در سناریوهای پیشرفتهتر (مثل Multi-Region) نیاز به مکانیزمهای پیامرسانی برای هماهنگی ابطال کش بین چند Redis جداگانه پیدا میکنید — که موضوع مقالات آینده است.
جمعبندی
الگوی Decorator راهی تمیز و اصولی برای اضافه کردن کشینگ به هر Repository یا Service است، بدون آلوده کردن منطق اصلی. نکات کلیدی:
- Interface را دستنخورده نگه دارید؛ کش باید کاملاً «شفاف» برای مصرفکننده باشد
- Decorator را با Composition بسازید، نه با ارثبری از کلاس اصلی
- هرگز فراموش نکنید بعد از Update/Delete، کلید مرتبط را از کش پاک کنید
- این الگو دقیقاً همان چیزی است که معماری Onion/Clean از شما انتظار دارد: جداسازی نگرانیها (Separation of Concerns) در حد کلاس