Опирается на правила: R-CACHE-WHERE-1R-CACHE-WHERE-3 и R-CACHE-WHERE-X1R-CACHE-WHERE-X5 из Caching Style Guide → раздел 1. Где кешируем.

Важно знать

  • Кеш — оптимизация, не часть бизнес-контракта. Сервис обязан работать корректно и без кеша.
  • Если «кеш обязателен для корректности» — это бизнес-данные, хранить в БД, не в кеше.
  • Read-heavy + редко меняющееся — да: справочники (часы), user profile (~15 мин), feature flags (~60 сек), heavy aggregations.
  • Money — допустимо, но TTL ≤ 30 секунд + cache.del на каждом write того же ресурса.
  • Cache-aside (get → miss → load → set с TTL; evict на write) — дефолтный паттерн в NestJS.
  • Доменный агрегат целиком — никогда: нарушает границы агрегата; invalidation становится неуправляемой. Кешируем read-проекции (OrderSummaryDto).
  • JWT/ABAC валидация не кешируется руками: JWK-кеш встроен в jwks-rsa (AUTH-5), ABAC — каждый раз (изменение ролей → security risk).
  • В NestJS нет @Cacheable-декоратора с семантикой Spring — явный cache-aside прямо в сервисе/адаптере; CacheInterceptor — только для простых HTTP-GET.

Кеш экономит latency и нагрузку на БД, но добавляет stale-data риск и invalidation-сложность. Главный вопрос перед тем, как добавить cache.get/set — «что произойдёт, если значение в кеше будет на 30 секунд устаревшим, и кто это заметит?»

Read-heavy + редко меняющееся — кешируем

R-CACHE-WHERE-1: типичные кандидаты по характеру данных.

ЧтоTTLПример
Справочникичасыстраны, валюты, тарифы, типы документов
User profile / settings~15 минутUserProfileDto, UserSettingsDto
Feature flags30–60 секундFeatureFlagSetDto
Heavy aggregations5–10 минутDailyReportDto, TopProductsDto
JWK Set5 минутOAuth публичные ключи (AUTH-5 — встроено в jwks-rsa)

Критерий: ratio read/write > 100:1, данные не критичны к immediate consistency, бизнес-смысл допускает задержку TTL.

// core/ports/out/user-profile-cache.port.ts
export interface UserProfileCachePort {
  get(userId: number): Promise<UserProfileDto | null>;
  set(userId: number, dto: UserProfileDto, ttlMs: number): Promise<void>;
  del(userId: number): Promise<void>;
}

// adapters/out/cache/user-profile-redis.adapter.ts
@Injectable()
export class UserProfileRedisAdapter implements UserProfileCachePort {
  private readonly NS = 'user-profiles';

  constructor(
    @Inject(CACHE_MANAGER) private readonly cache: Cache,
    private readonly cfg: CacheConfig,
  ) {}

  async get(userId: number): Promise<UserProfileDto | null> {
    return this.cache.get<UserProfileDto>(`${this.NS}:${userId}`);
  }

  async set(userId: number, dto: UserProfileDto, ttlMs: number): Promise<void> {
    await this.cache.set(`${this.NS}:${userId}`, dto, ttlMs);
  }

  async del(userId: number): Promise<void> {
    await this.cache.del(`${this.NS}:${userId}`);
  }
}

// application/use-cases/get-user-profile.handler.ts
@Injectable()
export class GetUserProfileHandler {
  constructor(
    private readonly repo: UserProfileRepository,
    private readonly cachePort: UserProfileCachePort,
    private readonly cfg: CacheConfig,
  ) {}

  async handle(query: GetUserProfileQuery): Promise<UserProfileDto> {
    const key = query.userId;
    const hit = await this.cachePort.get(key);
    if (hit) return hit;

    const profile = await this.repo.findById(key);
    if (!profile) throw new UserProfileNotFoundException(key);

    const dto = UserProfileDto.from(profile);
    await this.cachePort.set(key, dto, this.cfg.userProfilesTtlMs);
    return dto;
  }
}

Кеш-порт живёт в core/, реализация — в adapters/out/cache/ (cross-ref R-HEX-PORT-1). Это даёт возможность подменить backend в тестах без изменения handler'а.

Money — допустимо, но строго

R-CACHE-WHERE-2: баланс, лимиты, available credit можно кешировать только с явной invalidation strategy.

  • TTL 5–30 секунд (не больше).
  • cache.del на каждом write-методе того же ресурса.
  • Для критичных операций (списание, перевод) — cache.del перед чтением, явный обход кеша.
// application/use-cases/get-customer-balance.handler.ts
@Injectable()
export class GetCustomerBalanceHandler {
  constructor(
    private readonly repo: CustomerBalanceRepository,
    private readonly cachePort: CustomerBalanceCachePort,
    private readonly cfg: CacheConfig,
  ) {}

  async handle(query: GetCustomerBalanceQuery): Promise<BalanceDto> {
    const hit = await this.cachePort.get(query.customerId);
    if (hit) return hit;

    const balance = await this.repo.findBalance(query.customerId);
    const dto = BalanceDto.from(balance);
    await this.cachePort.set(query.customerId, dto, this.cfg.customerBalanceTtlMs);
    return dto;
  }
}

// application/use-cases/debit-customer-balance.handler.ts
@Injectable()
export class DebitCustomerBalanceHandler {
  constructor(
    private readonly repo: CustomerBalanceRepository,
    private readonly cachePort: CustomerBalanceCachePort,
  ) {}

  async handle(cmd: DebitCustomerBalanceCommand): Promise<BalanceDto> {
    await this.cachePort.del(cmd.customerId);

    const balance = await this.repo.findBalance(cmd.customerId);
    const updated = balance.subtract(cmd.amount);
    await this.repo.save(updated);

    await this.cachePort.del(cmd.customerId);
    return BalanceDto.from(updated);
  }
}

Double-evict (до и после) защищает от race: другой запрос мог положить старое значение в кеш между первым del и save.

Cache-aside — дефолтный паттерн

R-CACHE-WHERE-3: get → miss → load → set с TTL; evict на write.

Request → cache.get(key)
              │
              ├── HIT  → return dto
              │
              └── MISS → repo.find() → cache.set(key, dto, ttlMs) → return dto

Write → repo.update() → cache.del(key) → next read loads fresh

Простой, читаемый, предсказуемый. CacheInterceptor из @nestjs/cache-manager делает то же самое для HTTP-GET — но только на уровне HTTP: ключ = URL, TTL из @CacheTTL(). Для service-методов — всегда явный cache-aside как выше. Подробнее — Паттерны.

Что запрещено

Кеш на write-path

R-CACHE-WHERE-X1: CacheInterceptor на POST/PUT/DELETE — нонсенс.

// КАТАСТРОФА
@UseInterceptors(CacheInterceptor)
@Post('orders')
async createOrder(@Body() dto: CreateOrderDto): Promise<OrderDto> {
  return this.handler.handle(CreateOrderCommand.from(dto));
}

Сценарий: клиент шлёт POST /orders с customerId=42. Первый раз handler срабатывает, создаёт заказ. Второй раз — CacheInterceptor возвращает ответ первого вызова без вызова handler'а. Реально новый заказ не создаётся, но клиент думает, что создан.

CacheInterceptor имеет смысл только для GET: «для тех же входных данных — тот же ответ». Мутирующие операции порождают side-effects, кеш их прячет.

Кеш доменного агрегата целиком

R-CACHE-WHERE-X2: cache.set('orders:42', orderAggregate) — агрегат с items, payment, shipment.

Проблемы:

  • Нарушает границы агрегата. Aggregate сам управляет invariants; внешний кеш видит его внутренний dirty state.
  • Sensitive data. Aggregate может содержать поля, которые нельзя кешировать (PII, payment details).
  • Invalidation hell. Любой child-update (item.markShipped(), payment.refund()) должен evict-ить parent. Цепочку легко пропустить.
  • Размер. Полный aggregate с relation'ами — несколько KB, кеш заполняется быстро.

Корректно — read-проекции:

export class OrderSummaryDto {
  readonly orderId: number;
  readonly customerId: number;
  readonly customerName: string;
  readonly status: OrderStatus;
  readonly itemCount: number;
  readonly totalAmount: Dinero<number>;

  static from(row: OrderSummaryRow): OrderSummaryDto { /* ... */ }
}

// handler кеширует OrderSummaryDto, не Order-агрегат
async handle(query: GetOrderSummaryQuery): Promise<OrderSummaryDto> {
  const hit = await this.cachePort.get(query.orderId);
  if (hit) return hit;

  const row = await this.repo.findSummary(query.orderId);
  const dto = OrderSummaryDto.from(row);
  await this.cachePort.set(query.orderId, dto, this.cfg.orderSummariesTtlMs);
  return dto;
}

OrderSummaryDto — плоский plain object, без sensitive-данных, без nested collections. После Redis round-trip остаётся plain object (методов нет — это нормально для DTO).

Money без TTL/invalidation

R-CACHE-WHERE-X3: «кешируем баланс на час». Сценарий: клиент потратил, баланс в БД 0 ₽, в кеше 5000 ₽ ещё 55 минут. Открывает приложение → видит 5000 ₽ → пытается потратить → отказ → пишет в поддержку.

Money — отдельный класс данных, для него правила строже:

  • TTL ≤ 30 секунд (this.cfg.customerBalanceTtlMs).
  • cache.del на каждой write-операции.
  • Для критичных flows — cache.del перед чтением (защита от race).

Кеш бизнес-критичных данных без trade-off

R-CACHE-WHERE-X4: «кешируем, потому что быстрее» — без оценки stale-data риска.

Перед cache.set отвечаем:

  1. Что произойдёт при stale на TTL длительности? Кто пострадает?
  2. Можно ли это терпеть? (Список валют — да; счёт клиента — нет.)
  3. Есть ли явная invalidation strategy?

Если три «да» — кешируем. Иначе — нет.

Кеш валидации/авторизации

R-CACHE-WHERE-X5: соблазн кешировать «у пользователя 42 есть роль ADMIN» на 10 минут — security risk.

Сценарий: admin отозвал роль в 10:00. До 10:10 пользователь продолжает выполнять admin-действия, потому что кеш.

JWK-кеш встроен в jwks-rsa (используется passport-jwt). ABAC-проверки в guard'ах делаются каждый раз, без кеша. Цена — несколько ms на запрос; выгода — мгновенный revoke.

// ЗАПРЕЩЕНО
@Injectable()
export class AuthzService {
  private readonly rolesCache = new Map<number, string[]>();

  async hasRole(userId: number, role: string): Promise<boolean> {
    // кеш ролей — security risk при изменении
    if (!this.rolesCache.has(userId)) {
      this.rolesCache.set(userId, await this.loadRoles(userId));
    }
    return this.rolesCache.get(userId)!.includes(role);
  }
}

Что запрещено — таблица

АнтипаттернПравилоЧто взамен
CacheInterceptor на POST/PUT/DELETER-CACHE-WHERE-X1только GET; write — cache.del
cache.set с агрегатом целикомR-CACHE-WHERE-X2read-проекции (OrderSummaryDto)
Money-кеш без TTL/invalidationR-CACHE-WHERE-X3TTL ≤ 30s + явный cache.del
Кеш бизнес-данных без trade-off оценкиR-CACHE-WHERE-X4три вопроса перед cache.set
Кеш JWT/ABAC-проверки рукамиR-CACHE-WHERE-X5JWK встроен в jwks-rsa; ABAC — каждый раз
cache.set на write-heavy данныхR-CACHE-WHERE-3не кешировать вообще
Money TTL > 30s без double-evictR-CACHE-WHERE-2cache.del до и после repo.save()

Куда дальше

  • Конфигурация — CacheModule.registerAsync, ioredis-store, per-cache TTL из конфига.
  • TTL — типовые значения, TTL из CacheConfig, cache.set(key, dto, ttlMs).
  • Invalidation — cache.del, @OnEvent, Promise.all для нескольких кешей.
  • Паттерны — cache-aside vs write-through vs refresh-ahead с @Cron/@Interval.
  • Cache stampede — single-flight Map<string, Promise<T>> и redlock.
  • Ключи — namespace-префикс, kebab-case, хеширование sensitive в ключе.
  • Observability — prom-client counters в кеш-порте, hit rate alert.