Опирается на правила: R-CACHE-KEY-1R-CACHE-KEY-4 и R-CACHE-KEY-X1R-CACHE-KEY-X4 из Caching Style Guide → раздел 3. Ключи.

Важно знать

  • cache-manager не префиксует сам — namespace-префикс зашивается в кеш-порт или в функцию-билдер ключей вручную.
  • Имена кешей в kebab-case slug: user-profiles, order-summaries, feature-flags. Не camelCase, не snake_case.
  • Ключ всегда explicit: шаблонная строка `user-profiles:${userId}`, не String(dto) и не JSON.stringify(dto).
  • CacheInterceptor при HTTP-GET использует URL как ключ — для service-методов ключ указывается явно в коде.
  • JSON.stringify(dto) целого объекта в ключе ненадёжен: порядок полей не гарантирован, ключ нестабилен.
  • Composite-ключ — строки через : (Redis-конвенция вложенных namespace): `orders-by-customer:${customerId}:${status}`.
  • Sensitive данные в ключе (email, phone, токен) — хешировать через createHash('sha256'), не plain.
  • Один кеш на один тип данных — общий shared-cache ломает TTL, метрики и invalidation.

Ключ — адрес, по которому Redis находит значение. Плохой ключ даёт hit rate = 0% (новый ключ на каждый вызов), непредсказуемую invalidation и утечку чувствительных данных в логи. UCP требует, чтобы ключ был predictable, readable, secure.

Namespace через префикс имени кеша

R-CACHE-KEY-1: в отличие от Spring, @nestjs/cache-manager не добавляет префикс автоматически. Namespace реализуется через функцию-билдер ключей в кеш-порте.

// adapters/out/cache/user-profile-cache.adapter.ts
export class UserProfileCacheAdapter implements UserProfileCachePort {
  private key(userId: number): string {
    return `user-profiles:${userId}`;
  }

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

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

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

В Redis ключ выглядит так: user-profiles:42. Что это даёт:

  • Логическое разделение. KEYS user-profiles:* — все user-profile записи.
  • Evict-by-namespace. cache.del('user-profiles:42') — точечно; при необходимости сброса всего namespace — по паттерну через ioredis (SCAN + DEL).
  • Метрики per-namespace. Счётчики hits/misses в порте инкрементируются с тегом cache: 'user-profiles'.

Kebab-case slug для имени кеша

R-CACHE-KEY-2: имена — kebab-case, не camelCase.

user-profiles            ✓
order-summaries          ✓
payment-methods          ✓
feature-flags            ✓
product-catalogue        ✓
userProfiles             ✗  — camelCase
UserProfiles             ✗  — PascalCase
user_profiles            ✗  — snake_case

Соглашение: ключи Redis в UCP — slug-формата, как URL-пути и event-топики. Это даёт консистентность между user-profiles:42 (кеш), /user-profiles/42 (API) и user-profile.updated (событие).

Explicit-билдер ключа

R-CACHE-KEY-3: ключ строится явной шаблонной строкой — никаких «весь объект целиком».

Один параметр:

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

// adapters/out/cache/order-summary-cache.adapter.ts
private key(orderId: string): string {
  return `order-summaries:${orderId}`;
}

Composite-ключ:

// Заказы клиента по статусу — два измерения в ключе
private key(customerId: number, status: OrderStatus): string {
  return `orders-by-customer:${customerId}:${status}`;
}

async get(customerId: number, status: OrderStatus): Promise<OrderSummaryDto[] | null> {
  return this.cache.get<OrderSummaryDto[]>(this.key(customerId, status)) ?? null;
}

В Redis: orders-by-customer:42:CONFIRMED. Разделитель : — Redis-конвенция для вложенных namespace.

Объект-параметр — извлекаем нужные поля явно:

// Поиск продуктов по критериям — только значимые поля
private key(criteria: ProductSearchCriteria): string {
  return `product-search:${criteria.categoryId}:${criteria.minPrice}:${criteria.maxPrice}:${criteria.page}`;
}

Никогда JSON.stringify(criteria) — порядок полей в объекте не гарантирован движком, ключ нестабилен между вызовами.

Custom key-builder для сложных случаев

R-CACHE-KEY-4: когда полей 4+ или нужна нормализация (lowercase, сортировка массивов, обрезка дат) — выносим в отдельную функцию-билдер.

// adapters/out/cache/customer-orders-key.builder.ts
export class CustomerOrdersKeyBuilder {
  build(criteria: CustomerOrderSearchCriteria): string {
    const tags = [...criteria.tags].sort().join(',');
    const from = criteria.fromDate.toISODate();
    const to = criteria.toDate.toISODate();
    return `customer-orders:${criteria.customerId}:${criteria.status}:${from}:${to}:${criteria.page}:${tags}`;
  }
}
// adapters/out/cache/customer-orders-cache.adapter.ts
@Injectable()
export class CustomerOrdersCacheAdapter implements CustomerOrdersCachePort {
  constructor(
    @Inject(CACHE_MANAGER) private readonly cache: Cache,
    private readonly keyBuilder: CustomerOrdersKeyBuilder,
  ) {}

  async get(criteria: CustomerOrderSearchCriteria): Promise<CustomerOrderSummary[] | null> {
    return this.cache.get<CustomerOrderSummary[]>(this.keyBuilder.build(criteria)) ?? null;
  }
}

Когда применять: 4+ полей, нужна нормализация входных данных, или ключ нужен в нескольких местах (единая точка изменения).

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

АнтипаттернПравилоЧто взамен
JSON.stringify(dto) целого объекта в ключеR-CACHE-KEY-X2шаблонная строка с явными полями
String(dto) / dto.toString() (даёт [object Object])R-CACHE-KEY-X2шаблонная строка с явными полями
Автоключ CacheInterceptor на service-методахR-CACHE-KEY-X1explicit-ключ в кеш-порте
Один shared-cache для разных entityR-CACHE-KEY-X3per-entity namespace
Email / phone / токен в ключе plain-textR-CACHE-KEY-X4createHash('sha256')
camelCase / snake_case имя кешаR-CACHE-KEY-2kebab-case slug
Нестабильный порядок полей в composite-ключеR-CACHE-KEY-3сортировка перед сборкой ключа
Отсутствие namespace-префикса (ключ 42 без контекста)R-CACHE-KEY-1user-profiles:42

Один общий shared-cache

// ПЛОХО — один кеш для разных entity
async getUser(userId: number): Promise<UserProfileDto | null> {
  return this.cache.get<UserProfileDto>(`shared:user:${userId}`);
}

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

Проблемы:

  • TTL общий — UserProfileDto и OrderSummaryDto не могут иметь разное время жизни.
  • Метрики смешанные — hit/miss по типу данных не разделить.
  • cache.del('shared:*') при сбросе — удаляет всё.
// ХОРОШО — per-entity namespace
private userKey(userId: number) { return `user-profiles:${userId}`; }
private orderKey(orderId: string) { return `order-summaries:${orderId}`; }

Plain PII/токенов в ключе

R-CACHE-KEY-X4: Redis-ключи видны в redis-cli MONITOR, slow query log, сетевом трафике при non-TLS.

// ПЛОХО — email в ключе виден в логах
async getByEmail(email: string): Promise<UserProfileDto | null> {
  return this.cache.get<UserProfileDto>(`user-by-email:${email}`);
}

// ХОРОШО — хешируем email
import { createHash } from 'node:crypto';

private emailKey(email: string): string {
  const hash = createHash('sha256').update(email).digest('hex');
  return `user-by-email:${hash}`;
}

Лучше — не использовать sensitive значения как идентификаторы вообще. Внутренний customerId (BIGINT) идеален: ни в каком смысле не является персональными данными.

Куда дальше

  • Cache stampede — single-flight и redlock при cache miss.
  • Конфигурация — per-cache TTL из конфига, ioredis-store, fail-fast.
  • Invalidation — cache.del на write и @OnEvent-обработчики.
  • Observability — hit/miss-счётчики через prom-client.
  • Паттерны — cache-aside, write-through, refresh-ahead.
  • TTL — explicit TTL на каждый cache.set, типовые значения.
  • Где кешируем — какие данные кешировать, read-проекции вместо агрегатов.