Опирается на правила: R-CACHE-CFG-1R-CACHE-CFG-5 и R-CACHE-CFG-X1R-CACHE-CFG-X4 из Caching Style Guide → раздел 2. Конфигурация.

Важно знать

  • Redis в проде через @nestjs/cache-manager + cache-manager-ioredis-yet store; дефолтный in-memory store молча уезжает в K8s, где каждый pod имеет свой кеш.
  • JSON-сериализацияJSON.stringify / JSON.parse на plain DTO; v8.serialize и eval-подобное — аналог Java native-сериализации, security risk.
  • Класс-инстанс с методами в кеше неработоспособен: после round-trip через Redis это plain object без прототипа.
  • Per-cache TTL — каждый именованный кеш получает TTL из CacheConfig, не из единого ttl модуля.
  • CacheModule.register() (inline-конфиг) в проде нельзя — store не берётся из конфига; только CacheModule.registerAsync().
  • Fail-fast без Redis при старте: если Redis недоступен, сервис не поднимается — silent skip кеша хуже, чем явный сбой.
  • В тестах@testcontainers/redis или in-memory store; мок кеш-порта теряет поведение TTL и eviction.
  • cache.set(key, val) без TTL трактуется как infinite; Redis при достижении max-memory выкидывает по LRU без вашего контроля.

Redis вместо in-memory в проде

R-CACHE-CFG-1: prod-backend — Redis через cache-manager-ioredis-yet.

Почему не дефолтный in-memory store:

  • Multi-instance. 8 реплик order-service в K8s = 8 локальных кешей. OrderSummary для order-42 записана в pod-1, pod-3 её не знает. Пользователь, попавший на pod-3 после write, читает значение до изменения.
  • Invalidation race. cache.del('order-summaries:order-42') на pod-1 сбрасывает только локальный кеш. Остальные поды держат устаревшие значения до истечения TTL.
  • Observability. In-memory кеш — чёрный ящик; Redis — Redis Exporter для Prometheus, redis-cli, keyspace notifications.
// src/infrastructure/cache/cache.module.ts
import { CacheModule } from '@nestjs/cache-manager';
import { redisStore } from 'cache-manager-ioredis-yet';
import { CACHE_CONFIG, CacheConfig } from './cache.config';

CacheModule.registerAsync({
  isGlobal: true,
  inject: [CACHE_CONFIG],
  useFactory: (cfg: CacheConfig) => ({
    store: redisStore,
    host: cfg.host,
    port: cfg.port,
    // per-cache TTL выставляется в кеш-порте через cache.set(key, val, ttlMs)
  }),
})
// AVOID: CacheModule.register({ store: ... }) — inline конфиг, store не из env
// AVOID: CacheModule.register() — дефолтный in-memory store молча уезжает в прод

isGlobal: true позволяет инжектировать CACHE_MANAGER в любой модуль без явного импорта CacheModule.

JSON-сериализация — никогда v8.serialize

R-CACHE-CFG-2: значения в Redis — plain JSON.

cache-manager-ioredis-yet по умолчанию передаёт значения через JSON.stringify / JSON.parse. Важно не нарушать это:

// PREFER: plain DTO — JSON.stringify справится
interface OrderSummaryDto {
  orderId: string;
  status: string;
  totalRub: number;
}

// AVOID: класс-инстанс с методами
class OrderSummary {
  getTotal() { return this.totalRub; }  // после round-trip метод исчезнет
}

Почему не v8.serialize / бинарные форматы:

  1. Security. v8.deserialize исполняет недоверенный буфер из Redis — если attacker может записать blob в Redis, это remote code execution.
  2. Читаемость. redis-cli GET order-summaries:order-42 возвращает читаемый JSON при отладке. Binary — нет.
  3. Forward-compat. JSON.parse игнорирует неизвестные поля. Добавили поле в OrderSummaryDto → старые закешированные значения читаются без нового поля. v8 при изменении структуры бросает ошибку.

Класс-инстанс с методами в кеше — отдельная ловушка: JSON.stringify(instance) сохраняет поля, JSON.parse вернёт Object, метод getTotal() исчезнет. Кешируй только plain DTO-интерфейсы.

Per-cache TTL через CacheConfig

R-CACHE-CFG-3 + R-CACHE-CFG-4: каждый именованный кеш — свой TTL; TTL берётся из конфига, не хардкодится в вызовах.

// src/infrastructure/cache/cache.config.ts
import { IsString, IsInt, Min, Max, IsPositive } from 'class-validator';

export class CacheConfig {
  @IsString()
  host: string;

  @IsInt() @Min(1) @Max(65535)
  port: number;

  /** ms — TTL профиля клиента */
  @IsPositive()
  customerProfileTtlMs: number;

  /** ms — TTL каталога продуктов */
  @IsPositive()
  productCatalogTtlMs: number;

  /** ms — TTL feature-flags */
  @IsPositive()
  featureFlagsTtlMs: number;

  /** ms — TTL баланса (money-данные, короткий) */
  @IsPositive()
  balanceTtlMs: number;

  /** ms — TTL сводки заказа */
  @IsPositive()
  orderSummaryTtlMs: number;
}
# config/default.yaml
cache:
  host: localhost
  port: 6379
  customerProfileTtlMs: 900000    # 15m
  productCatalogTtlMs: 21600000   # 6h
  featureFlagsTtlMs: 60000        # 60s
  balanceTtlMs: 15000             # 15s
  orderSummaryTtlMs: 300000       # 5m

Использование TTL в кеш-порте:

// src/infrastructure/cache/order-summary.cache.ts
@Injectable()
export class OrderSummaryCache {
  private readonly prefix = 'order-summaries';

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

  async get(orderId: string): Promise<OrderSummaryDto | null> {
    return this.cache.get<OrderSummaryDto>(`${this.prefix}:${orderId}`) ?? null;
  }

  async set(orderId: string, dto: OrderSummaryDto): Promise<void> {
    await this.cache.set(`${this.prefix}:${orderId}`, dto, this.cfg.orderSummaryTtlMs);
  }

  async del(orderId: string): Promise<void> {
    await this.cache.del(`${this.prefix}:${orderId}`);
  }
}

Почему TTL — поле CacheConfig, а не константа в методе:

  • SRE регулирует TTL переменной окружения без пересборки образа.
  • Одно место декларирует все TTL сервиса — легко сравнить balanceTtlMs: 15000 и orderSummaryTtlMs: 300000.
  • @IsPositive() на каждом поле даёт ошибку при старте, если значение не передано или равно нулю.

R-CACHE-CFG-X3: единый TTL ttl: 900000 в CacheModule.registerAsync для customerProfile, balance и productCatalog одновременно — либо баланс устаревает на 15 минут, либо каталог пересчитывается каждые 15 секунд.

Структура кеш-порта

В NestJS нет декларативного @Cacheable; кеш — явный cache-aside через порт в core/:

// src/core/order/ports/order-summary-cache.port.ts
export interface OrderSummaryCachePort {
  get(orderId: string): Promise<OrderSummaryDto | null>;
  set(orderId: string, dto: OrderSummaryDto): Promise<void>;
  del(orderId: string): Promise<void>;
}

export const ORDER_SUMMARY_CACHE_PORT = Symbol('OrderSummaryCachePort');
// src/infrastructure/cache/order-summary.cache.ts
@Injectable()
export class OrderSummaryCacheAdapter implements OrderSummaryCachePort {
  // реализация выше
}
// src/order/order.module.ts
@Module({
  providers: [
    { provide: ORDER_SUMMARY_CACHE_PORT, useClass: OrderSummaryCacheAdapter },
    GetOrderSummaryHandler,
  ],
})
export class OrderModule {}

Порт живёт в core/ — domain-слой не зависит от @nestjs/cache-manager. Реализация в infrastructure/cache/ подключается через DI. Соответствует R-HEX-PORT-1.

Fail-fast без Redis

R-CACHE-CFG-X4: если backend недоступен, сервис не поднимается молча.

// src/infrastructure/cache/cache.module.ts
CacheModule.registerAsync({
  isGlobal: true,
  inject: [CACHE_CONFIG],
  useFactory: async (cfg: CacheConfig) => {
    const store = await redisStore({ host: cfg.host, port: cfg.port });
    // redisStore бросит при недоступном Redis — NestJS не запустится
    return { store };
  },
})

В dev-окружении, если Redis необязателен, — явный NullOrderSummaryCache с предупреждением при инициализации:

// src/infrastructure/cache/null-order-summary.cache.ts
@Injectable()
export class NullOrderSummaryCache implements OrderSummaryCachePort {
  constructor() {
    console.warn('[NullOrderSummaryCache] Redis unavailable — caching disabled (dev only)');
  }

  async get(_orderId: string): Promise<OrderSummaryDto | null> { return null; }
  async set(_orderId: string, _dto: OrderSummaryDto): Promise<void> {}
  async del(_orderId: string): Promise<void> {}
}

null-имплементация без предупреждения — silent skip: hit rate 0%, никто не замечает до Grafana.

В тестах — Testcontainers или in-memory store

R-CACHE-CFG-5: два режима тестирования кеша.

Unit-тест handler'а (проверить «второй вызов не пошёл в репозиторий»):

// test/order/get-order-summary.handler.spec.ts
describe('GetOrderSummaryHandler', () => {
  let handler: GetOrderSummaryHandler;
  let cachePort: jest.Mocked<OrderSummaryCachePort>;
  let repoPort: jest.Mocked<OrderRepoPort>;

  beforeEach(() => {
    cachePort = { get: jest.fn(), set: jest.fn(), del: jest.fn() };
    repoPort = { findSummary: jest.fn() };
    handler = new GetOrderSummaryHandler(cachePort, repoPort);
  });

  it('returns cached value without hitting repo', async () => {
    const dto: OrderSummaryDto = { orderId: 'order-42', status: 'CONFIRMED', totalRub: 5000 };
    cachePort.get.mockResolvedValue(dto);

    const result = await handler.handle({ orderId: 'order-42' });

    expect(result).toEqual(dto);
    expect(repoPort.findSummary).not.toHaveBeenCalled();
  });
});

Мок порта здесь правомерен — проверяется поведение handler'а, а не реализация кеша. Мок Cache-интерфейса @nestjs/cache-manager напрямую — нет: теряется TTL и eviction.

Integration-тест кеш-адаптера (проверить реальный Redis):

// test/infrastructure/order-summary.cache.spec.ts
import { RedisContainer } from '@testcontainers/redis';

describe('OrderSummaryCacheAdapter', () => {
  let container: StartedRedisContainer;
  let adapter: OrderSummaryCacheAdapter;

  beforeAll(async () => {
    container = await new RedisContainer('redis:7-alpine').start();
    const store = await redisStore({ url: container.getConnectionUrl() });
    const cache = cacheManager.create({ store });
    const cfg: CacheConfig = { ...defaultCacheConfig, orderSummaryTtlMs: 5000 };
    adapter = new OrderSummaryCacheAdapter(cache as Cache, cfg);
  });

  afterAll(() => container.stop());

  it('stores and retrieves OrderSummaryDto', async () => {
    const dto: OrderSummaryDto = { orderId: 'order-sber-42', status: 'PAID', totalRub: 12900 };
    await adapter.set(dto.orderId, dto);

    const result = await adapter.get(dto.orderId);
    expect(result).toEqual(dto);
  });

  it('returns null after TTL expires', async () => {
    const dto: OrderSummaryDto = { orderId: 'order-ttl-test', status: 'PENDING', totalRub: 100 };
    await adapter.set(dto.orderId, dto);

    await new Promise(r => setTimeout(r, 6000));
    const result = await adapter.get(dto.orderId);
    expect(result).toBeNull();
  }, 10_000);
});

TTL-тест с мок-интерфейсом бессмысленен — мок не умеет «забывать» значение через 5 секунд.

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

АнтипаттернПравилоЧто взамен
CacheModule.register() (in-memory store) в multi-instance продеR-CACHE-CFG-X2CacheModule.registerAsync() с redisStore
v8.serialize / бинарная десериализация значений из RedisR-CACHE-CFG-X1JSON.stringify / JSON.parse через cache-manager-ioredis-yet
Класс-инстанс с методами в кешеR-CACHE-CFG-2plain DTO-интерфейс
Единый TTL в CacheModule.registerAsync для всех кешейR-CACHE-CFG-X3отдельное поле CacheConfig на каждый кеш
cache.set(key, val) без TTLR-CACHE-TTL-X1cache.set(key, val, this.cfg.orderSummaryTtlMs)
TTL-литерал 900_000 прямо в вызовеR-CACHE-CFG-4поле CacheConfig с @IsPositive()
null-имплементация кеш-порта без предупрежденияR-CACHE-CFG-X4явный NullOrderSummaryCache с console.warn при инициализации
Мок Cache-интерфейса @nestjs/cache-manager в тестахR-CACHE-CFG-5OrderSummaryCachePort-мок в handler-тестах + Testcontainers в адаптер-тестах

Куда дальше

  • Где кешируем — какие данные кешировать, а какие нет.
  • Ключи — namespace-префикс, explicit-ключ, хеширование sensitive-данных.
  • TTL — типовые значения по характеру данных.
  • Invalidation — cache.del на write, @OnEvent-handler как триггер инвалидации.
  • Паттерны — cache-aside, write-through, refresh-ahead на NestJS-идиомах.
  • Cache stampede — single-flight Map<string, Promise> и redlock для distributed-кеша.
  • Observability — prom-client counters в кеш-порте, alert на hit rate.