Кешування

IMemoryCache: кеш в оперативній пам'яті

Детальний розгляд IMemoryCache в ASP.NET Core Minimal API: реєстрація, GetOrCreateAsync, AbsoluteExpiration vs SlidingExpiration, MemoryCacheEntryOptions, SizeLimit та eviction, PostEvictionCallback, захист від Cache Stampede через SemaphoreSlim, Best Practices та підводні камені.

IMemoryCache: кеш в оперативній пам'яті

IMemoryCache — найпростіший і найшвидший механізм кешування в ASP.NET Core. Він зберігає дані прямо в RAM поточного серверного процесу: жодного мережевого запиту, жодної серіалізації. Час доступу — наносекунди.

Це відправна точка для більшості застосунків. Додати його — один рядок у Program.cs, використати — виклик GetOrCreateAsync. Але за цією простотою ховається багато нюансів: правила про час закінчення, стратегії витіснення при нестачі пам'яті, тонкощі конкурентного доступу.

У цій статті ми будуємо CatalogApi — API каталогу товарів, покроково додаючи кешування від базового Get/Set до повноцінного production-патерну з Cache Stampede захистом. Кожен крок — відповідь на конкретний біль.

Реєстрація

Program.cs
var builder = WebApplication.CreateBuilder(args);

// Мінімальний варіант — нічого не налаштовано
builder.Services.AddMemoryCache();

// Або з деталями (більш production-ready)
builder.Services.AddMemoryCache(options =>
{
    // SizeLimit: максимальний розмір кешу в умовних одиницях.
    // Самостійно обираєте одиницю виміру (байти, кількість записів, тощо).
    // Кожен запис вказує свій .Size — разом вони не мають перевищувати SizeLimit.
    options.SizeLimit = 1024;

    // CompactionPercentage: який відсоток кешу видалити при досягненні SizeLimit.
    // 0.25 = видалити 25% кешу (найстаріші/найнижчого пріоритету записи)
    options.CompactionPercentage = 0.25;

    // ExpirationScanFrequency: як часто перевіряти закінчені записи
    // За замовчуванням — 1 хвилина, але для часто змінюваних кешів — можна зменшити
    options.ExpirationScanFrequency = TimeSpan.FromMinutes(1);

    // TrackStatistics (за замовчуванням false): вмикає детальну статистику кешу
    // Використовуйте в dev/staging для аналізу hit rate
    options.TrackStatistics = builder.Environment.IsDevelopment();
});

Після реєстрації IMemoryCache доступний через DI у будь-якому сервісі чи ендпоінті.


Базові операції: Set, Get, TryGetValue, Remove

CacheBasics.cs
using Microsoft.Extensions.Caching.Memory;

// Ін'єкція через конструктор
public class ProductRepository
{
    private readonly IMemoryCache _cache;
    private readonly AppDbContext _db;

    public ProductRepository(IMemoryCache cache, AppDbContext db)
    {
        _cache = cache;
        _db = db;
    }

    // ─── Set: записати в кеш ──────────────────────────────────
    public void CacheProduct(Product product)
    {
        var key = $"product:{product.Id}";

        // Простий Set — зберігає без TTL (живе до перезапуску або eviction)
        _cache.Set(key, product);

        // Set з TTL
        _cache.Set(key, product, TimeSpan.FromMinutes(15));

        // Set з DateTimeOffset — абсолютний час закінчення
        _cache.Set(key, product, DateTimeOffset.UtcNow.AddMinutes(15));
    }

    // ─── Get: отримати з кешу (повертає null якщо відсутній) ──
    public Product? GetCached(int id)
    {
        return _cache.Get<Product>($"product:{id}");
        // Повертає null якщо ключ не знайдено або закінчився
    }

    // ─── TryGetValue: безпечна перевірка без allocations ──────
    public Product? GetCachedSafe(int id)
    {
        var key = $"product:{id}";

        if (_cache.TryGetValue(key, out Product? cached))
        {
            // Cache HIT: повертаємо закешований об'єкт
            return cached;
        }

        // Cache MISS: читаємо з БД
        var product = _db.Products.Find(id);
        if (product is not null)
            _cache.Set(key, product, TimeSpan.FromMinutes(15));

        return product;
    }

    // ─── Remove: примусово видалити ───────────────────────────
    public void InvalidateProduct(int id)
    {
        _cache.Remove($"product:{id}");
        _cache.Remove("products:all"); // Також інвалідуємо список
    }
}

TryGetValue — кращий вибір за Get, оскільки не потребує повторного пошуку при cache miss. Get = TryGetValue + повернення null, але з одним TryGetValue ви уникаєте подвійного пошуку в внутрішньому словнику кешу.


GetOrCreateAsync: головний патерн

Ручне TryGetValue + Set — це боilerplate. GetOrCreateAsync (і синхронний GetOrCreate) — скорочений запис цього патерну:

Services/ProductService.cs
public class ProductService
{
    private readonly IMemoryCache _cache;
    private readonly AppDbContext _db;
    private readonly ILogger<ProductService> _logger;

    // Ключі кешу — константи, щоб уникнути typos і magic strings
    private const string AllProductsKey  = "products:all";
    private const string ProductKeyPrefix = "products:id:";

    public ProductService(
        IMemoryCache cache,
        AppDbContext db,
        ILogger<ProductService> logger)
    {
        _cache = cache;
        _db = db;
        _logger = logger;
    }

    public async Task<List<Product>> GetAllAsync(CancellationToken ct = default)
    {
        // GetOrCreateAsync:
        // 1. Якщо ключ є в кеші → повертає закешоване значення (factory НЕ викликається)
        // 2. Якщо ключа немає → викликає factory, зберігає результат, повертає його
        var result = await _cache.GetOrCreateAsync(
            key: AllProductsKey,
            factory: async entry =>
            {
                // entry — це MemoryCacheEntryOptions, де налаштовуємо TTL
                entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(30);

                _logger.LogInformation(
                    "Cache MISS [{Key}] — hitting database", AllProductsKey);

                // Дорогий запит — виконується тільки при cache miss
                return await _db.Products
                    .AsNoTracking()
                    .ToListAsync(ct);
            });

        return result ?? [];
    }

    public async Task<Product?> GetByIdAsync(int id, CancellationToken ct = default)
    {
        var key = $"{ProductKeyPrefix}{id}";

        return await _cache.GetOrCreateAsync(key, async entry =>
        {
            // Комбінований TTL: absolute + sliding
            entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(1);
            entry.SlidingExpiration = TimeSpan.FromMinutes(15);

            return await _db.Products
                .AsNoTracking()
                .FirstOrDefaultAsync(p => p.Id == id, ct);
        });
    }
}

Час закінчення: AbsoluteExpiration vs SlidingExpiration

Це один із найважливіших вибірів при кешуванні — коли запис вважається застарілим?

AbsoluteExpiration

Запис завжди видаляється рівно через N часу після створення, незалежно від того, скільки разів до нього зверталися.

// Варіант 1: відносний час від моменту створення
entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(30);
// Запис створено о 10:00 → видаляється о 10:30, незалежно від доступів

// Варіант 2: абсолютний DateTimeOffset
entry.AbsoluteExpiration = DateTimeOffset.UtcNow.Date.AddDays(1);
// Видалити рівно опівночі (корисно для кешу, що "скидається" щодня)

entry.AbsoluteExpiration = new DateTimeOffset(
    DateTime.Today.AddDays(1), TimeZoneInfo.FindSystemTimeZoneById("FLE Standard Time").GetUtcOffset(DateTime.Today));
// Видалити опівночі за Київським часом

Коли використовувати: для даних, що мають гарантовано оновлюватись через певний час, навіть якщо запитуються постійно. Наприклад: прайс-лист оновлюється раз на годину — кеш 60 хвилин.

SlidingExpiration

Запис видаляється якщо до нього не зверталися протягом N часу. Кожен доступ "скидає годинник".

entry.SlidingExpiration = TimeSpan.FromMinutes(10);
// Запис створено о 10:00
// Запитано о 10:08 → TTL скидається: видаляється не о 10:10, а о 10:18
// Не запитували 10 хвилин → видаляється

// Проблема: якщо запис запитується постійно — він НЕ видаляється НІКОЛИ
// (доки є місце в кеші)

Коли використовувати: для підтримки "гарячих" записів у кеші (популярні товари) і автоматичного видалення неактивних (рідко запитувані). Але без Absolute обмеження — записи можуть жити вічно.

Комбінований підхід (найкращий)

// Абсолютне обмеження: не більше 1 години
entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(1);

// Sliding: видалити раніше якщо неактивний 15 хвилин
entry.SlidingExpiration = TimeSpan.FromMinutes(15);

// Результат:
// → Популярні записи живуть до 1 години
// → Непопулярні видаляються через 15 хвилин неактивності
// → Жоден запис не живе більше 1 години (гарантія свіжості)

MemoryCacheEntryOptions: повний контроль

MemoryCacheEntryOptions.cs
public async Task<Product?> GetProductWithFullOptionsAsync(int id)
{
    var key = $"product:{id}";

    using var cacheEntry = _cache.CreateEntry(key);

    // ─── Час життя ───────────────────────────────────────────
    cacheEntry.AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(1);
    cacheEntry.SlidingExpiration = TimeSpan.FromMinutes(15);

    // ─── Розмір (для SizeLimit eviction) ────────────────────
    // Умовний розмір: наприклад, 1 одиниця = 1 запис (незалежно від фактичного розміру)
    // Або: фактичний розмір у байтах (потребує серіалізації для оцінки)
    cacheEntry.Size = 1;

    // ─── Пріоритет при eviction ──────────────────────────────
    // Коли кеш переповнений і треба щось видалити — видаляється з нижчим пріоритетом
    cacheEntry.Priority = CacheItemPriority.Normal;
    // Варіанти:
    // CacheItemPriority.Low     — видаляється першим
    // CacheItemPriority.Normal  — за замовчуванням
    // CacheItemPriority.High    — видаляється в останню чергу
    // CacheItemPriority.NeverRemove — НІКОЛИ не видаляється автоматично (обережно!)

    // ─── ExpirationTokens — зовнішні тригери інвалідації ────
    // CancellationChangeToken: при скасуванні токена — запис видаляється
    var cts = new CancellationTokenSource();
    cacheEntry.ExpirationTokens.Add(
        new CancellationChangeToken(cts.Token));
    // Пізніше: cts.Cancel() → запис видаляється з кешу

    // ─── PostEvictionCallbacks — реакція на видалення ───────
    cacheEntry.PostEvictionCallbacks.Add(new PostEvictionCallbackRegistration
    {
        EvictionCallback = (evictedKey, value, reason, state) =>
        {
            _logger.LogInformation(
                "Cache entry '{Key}' evicted. Reason: {Reason}. " +
                "Value type: {Type}",
                evictedKey, reason, value?.GetType().Name);
            // reason може бути:
            // EvictionReason.Expired    — закінчився TTL
            // EvictionReason.Removed    — явно видалено через Remove()
            // EvictionReason.Replaced   — перезаписано через Set()
            // EvictionReason.Capacity   — видалено через нестачу пам'яті (SizeLimit)
            // EvictionReason.TokenExpired — спрацював ExpirationToken
        }
    });

    // Завантажуємо значення
    var product = await _db.Products.FindAsync(id);
    cacheEntry.Value = product;
    // CreateEntry + SetValue = аналог Set(), але з повним контролем options

    return product;
}

ExpirationTokens: залежні кеші

ExpirationToken — потужний механізм: один запис кешу може "слідкувати" за іншим. Коли батьківський запис видаляється — дочірній видаляється автоматично.

// Приклад: кеш категорії і кеш товарів категорії — пов'язані
public void CacheCategoryWithProducts(Category category, List<Product> products)
{
    // 1. Створюємо запис категорії з CancellationTokenSource
    var categoryCts = new CancellationTokenSource();
    var categoryEntry = new MemoryCacheEntryOptions()
        .SetAbsoluteExpiration(TimeSpan.FromHours(1));

    _cache.Set($"category:{category.Id}", category, categoryEntry);

    // 2. Запис товарів слідкує за токеном категорії
    var productsEntry = new MemoryCacheEntryOptions()
        .SetAbsoluteExpiration(TimeSpan.FromHours(1))
        .AddExpirationToken(new CancellationChangeToken(categoryCts.Token));

    _cache.Set($"category:{category.Id}:products", products, productsEntry);

    // 3. При видаленні категорії — товари видаляються автоматично
    // categoryCts.Cancel() → обидва записи видаляються
}

SizeLimit та інтелектуальне витіснення (Eviction)

Без SizeLimit кеш може необмежено рости. SizeLimit встановлює максимальну суму Size всіх записів:

Program.cs
builder.Services.AddMemoryCache(opts =>
{
    // Максимум 500 умовних одиниць (наприклад, 500 записів по 1 одиниці кожен)
    opts.SizeLimit = 500;
    opts.CompactionPercentage = 0.25; // При досягненні ліміту — видалити 25%
});
Використання з Size
// Кожен запис вказує свій Size
_cache.Set("tiny-config", config, new MemoryCacheEntryOptions
{
    Size = 1,                       // 1 умовна одиниця
    Priority = CacheItemPriority.High
});

_cache.Set("large-product-list", products, new MemoryCacheEntryOptions
{
    Size = 50,                      // 50 умовних одиниць (великий об'єкт)
    Priority = CacheItemPriority.Normal
});
Якщо SizeLimit задано, але для запису НЕ задано Size — .NET кине InvalidOperationException при спробі додати запис. Або можна задати MemoryCacheOptions.SizeLimit = null (за замовчуванням).

Порядок витіснення при переповненні

Коли сума Size досягає SizeLimit, IMemoryCache видаляє записи у порядку:

  1. CacheItemPriority.Low — першими
  2. CacheItemPriority.Normal — другими
  3. CacheItemPriority.High — третіми
  4. CacheItemPriority.NeverRemove — ніколи (але займають місце!)

Серед одного пріоритету — спочатку видаляються найстаріші (Least Recently Used, LRU).


Моніторинг: MemoryCacheStatistics

Program.cs — TrackStatistics
builder.Services.AddMemoryCache(opts =>
{
    opts.TrackStatistics = true; // Лише у Development — має overhead
});
Endpoints/AdminEndpoints.cs
// GET /admin/cache/stats
app.MapGet("/admin/cache/stats", (IMemoryCache cache) =>
{
    if (cache is not MemoryCache memCache)
        return Results.BadRequest("Statistics not available");

    var stats = memCache.GetCurrentStatistics();
    if (stats is null)
        return Results.BadRequest("TrackStatistics is disabled");

    return Results.Ok(new
    {
        TotalHits = stats.TotalHits,
        TotalMisses = stats.TotalMisses,
        HitRate = stats.TotalHits + stats.TotalMisses == 0 ? 0 :
            (double)stats.TotalHits / (stats.TotalHits + stats.TotalMisses) * 100,
        TotalEntries = stats.CurrentEntryCount,
        EstimatedSize = stats.CurrentEstimatedSize
    });
});

Cache Stampede: проблема та рішення

Cache Stampede (або Thundering Herd) — катастрофічна ситуація при паралельному навантаженні:

// ❌ Небезпечно при 1000 рівночасних запитів:
var products = await _cache.GetOrCreateAsync("products:all", async entry => {
    entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(30);
    return await _db.Products.ToListAsync(); // 1000 паралельних SQL-запитів!
});

Коли кеш закінчується, всі 1000 запитів одночасно виявляють cache miss і виконують дорогий SQL-запит паралельно. База даних отримує 1000 одночасних запитів замість 1.

Рішення: SemaphoreSlim per-key

Services/SafeCacheService.cs
using System.Collections.Concurrent;

public class SafeCacheService
{
    private readonly IMemoryCache _cache;
    private readonly ILogger<SafeCacheService> _logger;

    // Словник семафорів: кожен ключ кешу — окремий SemaphoreSlim(1,1)
    // ConcurrentDictionary для thread-safe доступу
    private readonly ConcurrentDictionary<string, SemaphoreSlim> _locks = new();

    public SafeCacheService(IMemoryCache cache, ILogger<SafeCacheService> logger)
    {
        _cache = cache;
        _logger = logger;
    }

    public async Task<T?> GetOrCreateSafeAsync<T>(
        string key,
        Func<CancellationToken, Task<T?>> factory,
        TimeSpan absoluteExpiration,
        TimeSpan? slidingExpiration = null,
        CancellationToken ct = default)
    {
        // КРОК 1: Перша перевірка без блокування (fast path)
        // 99% запитів при cache hit завершаться тут — без overhead семафора
        if (_cache.TryGetValue(key, out T? cached))
        {
            _logger.LogDebug("Cache HIT (fast path): {Key}", key);
            return cached;
        }

        // КРОК 2: Cache miss — захоплюємо семафор для цього ключа
        // GetOrAdd гарантує thread-safe створення семафора
        var semaphore = _locks.GetOrAdd(key, _ => new SemaphoreSlim(1, 1));

        // WaitAsync — якщо семафор зайнятий, чекаємо (без блокування потоку)
        await semaphore.WaitAsync(ct);
        try
        {
            // КРОК 3: Друга перевірка після захоплення семафора
            // Поки ми чекали — інший потік міг вже заповнити кеш
            if (_cache.TryGetValue(key, out cached))
            {
                _logger.LogDebug("Cache HIT (after acquiring lock): {Key}", key);
                return cached;
            }

            _logger.LogInformation("Cache MISS — executing factory for: {Key}", key);

            // КРОК 4: Тільки ОДИН потік виконує дорогу операцію
            var result = await factory(ct);

            if (result is null) return default;

            // КРОК 5: Записуємо в кеш
            var options = new MemoryCacheEntryOptions
            {
                AbsoluteExpirationRelativeToNow = absoluteExpiration
            };
            if (slidingExpiration.HasValue)
                options.SlidingExpiration = slidingExpiration.Value;

            _cache.Set(key, result, options);
            return result;
        }
        finally
        {
            // ЗАВЖДИ звільняємо семафор — навіть при винятку
            semaphore.Release();
            // Видаляємо семафор якщо він більше не потрібен
            // (нова задача — новий семафор, нема memory leak)
            _locks.TryRemove(key, out _);
        }
    }
}

Реєстрація і використання:

Program.cs
builder.Services.AddSingleton<SafeCacheService>();
// Singleton важливий: словник семафорів має бути shared між запитами
Services/ProductService.cs
public async Task<List<Product>> GetAllSafeAsync(CancellationToken ct)
{
    return await _cacheService.GetOrCreateSafeAsync(
        key: "products:all",
        factory: async cancelToken =>
            await _db.Products.AsNoTracking().ToListAsync(cancelToken),
        absoluteExpiration: TimeSpan.FromMinutes(30),
        slidingExpiration: TimeSpan.FromMinutes(10),
        ct: ct) ?? [];
}

Інвалідація: стратегії видалення

Явна інвалідація

Services/ProductService.cs
public async Task<Product> CreateAsync(CreateProductRequest request, CancellationToken ct)
{
    var product = new Product { Name = request.Name, Price = request.Price };
    _db.Products.Add(product);
    await _db.SaveChangesAsync(ct);

    // Список товарів тепер застарілий — видаляємо
    _cache.Remove("products:all");

    return product;
}

public async Task<bool> UpdateAsync(int id, UpdateProductRequest req, CancellationToken ct)
{
    var product = await _db.Products.FindAsync([id], ct);
    if (product is null) return false;

    product.Name = req.Name;
    product.Price = req.Price;
    await _db.SaveChangesAsync(ct);

    // Видаляємо і список, і конкретний товар
    _cache.Remove("products:all");
    _cache.Remove($"products:id:{id}");

    return true;
}

Групова інвалідація через CancellationToken

Services/CategoryService.cs
// Для групи пов'язаних записів — один CancellationTokenSource
private CancellationTokenSource _categoriesCts = new();

public void CacheCategory(int categoryId, Category category)
{
    _cache.Set($"category:{categoryId}", category, new MemoryCacheEntryOptions
    {
        AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(1),
        // Прив'язуємо до спільного токена
        ExpirationTokens = { new CancellationChangeToken(_categoriesCts.Token) }
    });
}

// При зміні будь-якої категорії — видаляємо ВСІ закешовані категорії
public void InvalidateAllCategories()
{
    // Cancel() тригерить видалення всіх записів з цим токеном
    _categoriesCts.Cancel();
    // Створюємо новий CTS для майбутніх записів
    _categoriesCts = new CancellationTokenSource();
}

Типові помилки


Практичний приклад: повний ProductService

Services/ProductService.cs
public class ProductService
{
    private readonly IMemoryCache _cache;
    private readonly AppDbContext _db;
    private readonly SafeCacheService _safeCache;
    private readonly ILogger<ProductService> _logger;

    private const string AllKey  = "products:all";
    private const string IdPrefix = "products:id:";

    public ProductService(
        IMemoryCache cache,
        AppDbContext db,
        SafeCacheService safeCache,
        ILogger<ProductService> logger)
    {
        _cache = cache;
        _db = db;
        _safeCache = safeCache;
        _logger = logger;
    }

    // Список — захищений від Stampede, 30 хвилин кеш
    public Task<List<Product>?> GetAllAsync(CancellationToken ct) =>
        _safeCache.GetOrCreateSafeAsync(
            AllKey,
            async cancelToken => await _db.Products.AsNoTracking().ToListAsync(cancelToken),
            absoluteExpiration: TimeSpan.FromMinutes(30),
            slidingExpiration: TimeSpan.FromMinutes(10),
            ct: ct);

    // Конкретний товар — 1 година, sliding 15 хвилин
    public Task<Product?> GetByIdAsync(int id, CancellationToken ct) =>
        _safeCache.GetOrCreateSafeAsync(
            $"{IdPrefix}{id}",
            async cancelToken => await _db.Products.FindAsync([id], cancelToken),
            absoluteExpiration: TimeSpan.FromHours(1),
            slidingExpiration: TimeSpan.FromMinutes(15),
            ct: ct);

    // Запис — інвалідація всіх пов'язаних ключів
    public async Task<Product> CreateAsync(CreateProductRequest req, CancellationToken ct)
    {
        var p = new Product { Name = req.Name, Price = req.Price };
        _db.Products.Add(p);
        await _db.SaveChangesAsync(ct);
        _cache.Remove(AllKey);
        return p;
    }

    public async Task<bool> UpdateAsync(int id, UpdateProductRequest req, CancellationToken ct)
    {
        var p = await _db.Products.FindAsync([id], ct);
        if (p is null) return false;
        p.Name = req.Name; p.Price = req.Price;
        await _db.SaveChangesAsync(ct);
        _cache.Remove(AllKey);
        _cache.Remove($"{IdPrefix}{id}");
        return true;
    }

    public async Task<bool> DeleteAsync(int id, CancellationToken ct)
    {
        var p = await _db.Products.FindAsync([id], ct);
        if (p is null) return false;
        _db.Products.Remove(p);
        await _db.SaveChangesAsync(ct);
        _cache.Remove(AllKey);
        _cache.Remove($"{IdPrefix}{id}");
        return true;
    }
}

Практичні завдання

Рівень 1 — Базовий

Завдання 1.1. Реалізуйте endpoint GET /admin/cache/entries що за допомогою MemoryCache GetCurrentStatistics() повертає кількість записів, загальний розмір, кількість hits та misses. Endpoint має бути доступний тільки при builder.Environment.IsDevelopment().

Завдання 1.2. Додайте заголовок X-Cache: HIT або X-Cache: MISS до HTTP-відповіді використовуючи IMemoryCache.TryGetValue перед GetOrCreateAsync. Передавайте HttpContext у ProductService або реалізуйте окремий CacheHeaderMiddleware.

Рівень 2 — Логіка

Завдання 2.1. Реалізуйте Cache Warming: IHostedService що при старті завантажує в кеш перші 50 найпопулярніших товарів (за полем ViewCount). Додайте endpoint POST /admin/cache/warm для ручного прогріву. Логуйте час виконання через Stopwatch.

Завдання 2.2. Реалізуйте CacheInvalidationService з методами InvalidateProduct(int id), InvalidateAllProducts(), InvalidateCategory(int id), InvalidateAll(). Використовуйте CancellationTokenSource для групової інвалідації. Зареєструйте як Singleton і впровадьте у ProductService та CategoryService.

Рівень 3 — Архітектура

Завдання 3.1. Реалізуйте Generic Cache Decorator (CachedProductRepository) що обгортає IProductRepository і автоматично кешує результати методів GetAll, GetById. Використовуйте атрибут [Cacheable(Key = "...", Duration = 60)] для маркування методів і Reflection для зчитування атрибута у декораторі. Зареєструйте через decorator pattern у DI.

Copyright © 2026