Опирается на правила:
R-CACHE-WHERE-1…R-CACHE-WHERE-3иR-CACHE-WHERE-X1…R-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 flags | 30–60 секунд | FeatureFlagSetDto |
| Heavy aggregations | 5–10 минут | DailyReportDto, TopProductsDto |
| JWK Set | 5 минут | 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 отвечаем:
- Что произойдёт при stale на TTL длительности? Кто пострадает?
- Можно ли это терпеть? (Список валют — да; счёт клиента — нет.)
- Есть ли явная 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/DELETE | R-CACHE-WHERE-X1 | только GET; write — cache.del |
cache.set с агрегатом целиком | R-CACHE-WHERE-X2 | read-проекции (OrderSummaryDto) |
| Money-кеш без TTL/invalidation | R-CACHE-WHERE-X3 | TTL ≤ 30s + явный cache.del |
| Кеш бизнес-данных без trade-off оценки | R-CACHE-WHERE-X4 | три вопроса перед cache.set |
| Кеш JWT/ABAC-проверки руками | R-CACHE-WHERE-X5 | JWK встроен в jwks-rsa; ABAC — каждый раз |
cache.set на write-heavy данных | R-CACHE-WHERE-3 | не кешировать вообще |
| Money TTL > 30s без double-evict | R-CACHE-WHERE-2 | cache.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-clientcounters в кеш-порте, hit rate alert.