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

Важно знать

  • Кеш — оптимизация, не часть бизнес-контракта. Сервис обязан работать корректно и без кеша.
  • Если «кеш обязателен для корректности» — это бизнес-данные, надо хранить в БД, не в кеше.
  • Read-heavy + редко меняющееся — да: справочники (часы), user profile (15-30 мин), feature flags (60 сек), heavy aggregations.
  • Money — допустимо, но TTL ≤ 30 секунд + @CacheEvict на каждом write того же ресурса.
  • Cache-aside (lazy load + write evict) — дефолтный паттерн.
  • Доменный агрегат целиком — никогда: нарушает границы агрегата; invalidation становится сложной. Кешируем read-проекции (OrderSummary).
  • JWT/ABAC валидация не кешируется руками: JWK уже встроен (AUTH-5), ABAC — каждый раз (изменение roles → security risk).

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

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

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

ЧтоTTLПример
Справочникичасыстраны, валюты, тарифы, типы документов
User profile / settings15-30 минутUserProfile, UserSettings
Feature flags30-60 секундFeatureFlagSet
Heavy aggregations5-10 минутDailyReport, TopProducts
JWK Set5 минутOAuth публичные ключи (AUTH-5 — встроено)

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

@UseCase
@RequiredArgsConstructor
public class GetUserProfileHandler implements UseCaseHandler<GetUserProfileQuery, UserProfile> {

    private final UserProfileRepository userProfileRepository;

    @Override
    @Cacheable(cacheNames = "user-profiles", key = "#query.userId()")
    @Transactional(readOnly = true)
    public UserProfile handle(GetUserProfileQuery query) {
        return userProfileRepository.findById(query.userId())
            .orElseThrow(() -> new UserProfileNotFoundException(query.userId()));
    }
}

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

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

  • TTL 5-30 секунд (не больше).
  • @CacheEvict на каждом write-методе того же ресурса.
  • Для критичных операций (списание, перевод) — cache.evict(...) перед чтением, явный обход кеша.
@UseCase
@RequiredArgsConstructor
public class GetUserBalanceHandler implements UseCaseHandler<GetUserBalanceQuery, Money> {

    private final UserBalanceRepository balanceRepository;

    @Override
    @Cacheable(cacheNames = "user-balances", key = "#query.userId()")
    @Transactional(readOnly = true)
    public Money handle(GetUserBalanceQuery query) {
        return balanceRepository.findBalance(query.userId());
    }
}

@UseCase
@RequiredArgsConstructor
public class DebitUserBalanceHandler implements UseCaseHandler<DebitUserBalanceCommand, Money> {

    private final UserBalanceRepository balanceRepository;
    private final CacheManager cacheManager;

    @Override
    @Transactional
    public Money handle(DebitUserBalanceCommand command) {
        cacheManager.getCache("user-balances").evict(command.userId());
        var balance = balanceRepository.findBalance(command.userId());
        var updated = balance.subtract(command.amount());
        balanceRepository.save(updated);
        cacheManager.getCache("user-balances").evict(command.userId());
        return updated;
    }
}

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

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

R-CACHE-WHERE-3: read через @Cacheable, write — @CacheEvict или cache.evict().

Request → check cache
              │
              ├── HIT  → return cached
              │
              └── MISS → load from DB → put in cache → return

Write → DB update → evict cache → next read loads fresh

Простой, читаемый, предсказуемый. Подробнее — Паттерны.

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

Кеш на write-методах

R-CACHE-WHERE-X1: @Cacheable на createOrder(), confirmPayment() — нонсенс.

// КАТАСТРОФА
@Cacheable(cacheNames = "orders", key = "#command.customerId()")
public Order create(CreateOrderCommand command) {
    return orderRepository.save(...);
}

Сценарий: клиент шлёт CreateOrderCommand(customerId=42, items=[...]). Первый раз handler срабатывает, создаёт заказ. Второй раз — customerId тот же, Spring возвращает закешированный Order из первого вызова. Реально новый заказ не создаётся, но клиент думает, что создан.

@Cacheable имеет смысл только для read-методов: «для тех же параметров — тот же результат». Write-операции порождают side-effects, кеш их прячет.

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

R-CACHE-WHERE-X2: @Cacheable на OrderRepository.findById(id)Order aggregate с List<OrderItem> + Payment + Shipment.

Проблемы:

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

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

@Cacheable(cacheNames = "order-summaries", key = "#query.orderId()")
public OrderSummary handle(GetOrderSummaryQuery query) {
    return orderSummaryRepository.findById(query.orderId());
}

public record OrderSummary(
    Long orderId,
    Long customerId,
    String customerName,
    OrderStatus status,
    int itemCount,
    BigDecimal totalAmount
) {}

OrderSummary — небольшой record, без sensitive-данных, без child-collections. Кешируется без проблем.

Money без TTL/invalidation

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

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

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

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

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

Перед @Cacheable отвечаем:

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

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

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

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

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

JWT-валидация имеет встроенный JWK кеш (5 минут — AUTH-5). ABAC-проверки (@PreAuthorize("@access.canEditOrder(authentication, #orderId)")) делаются каждый раз, без кеша. Цена — пара ms на запрос, выгода — мгновенный revoke.

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

АнтипаттернПравилоЧто взамен
@Cacheable на write-методеR-CACHE-WHERE-X1только read, write — @CacheEvict
Кеш доменного агрегата целикомR-CACHE-WHERE-X2read-проекции (OrderSummary)
Money-кеш без TTL/invalidationR-CACHE-WHERE-X3TTL ≤ 30s + явный evict
Кеш бизнес-данных без trade-off оценкиR-CACHE-WHERE-X4три вопроса перед @Cacheable
Кеш JWT/ABAC-валидации рукамиR-CACHE-WHERE-X5JWK встроен; ABAC — каждый раз
Cache-aside для write-heavyR-CACHE-WHERE-3не кешируй вообще
Кеш > 1 минуты для money без double-evictR-CACHE-WHERE-2TTL ≤ 30s + evict before/after write

Куда дальше

  • Caching → раздел 1. Где кешируем — нормативные формулировки.
  • Конфигурация — RedisCacheManager, JSON serializer.
  • TTL — типовые значения, политики истечения.
  • Invalidation — @CacheEvict, @EventListener, distributed.
  • Паттерны — cache-aside vs write-through vs refresh-ahead.
  • CQRS → read-model — почему read-проекция, а не aggregate.
  • Auth → AUTH-5 — встроенный JWK кеш.