Cache storefront price lookups
Java · Spring Boot · intermediate · modification
The product grid calls `priceFor` once per tile, and the contract-price lookup behind it is now a high-volume query. This turns on Spring's cache abstraction with a new `CacheConfig` (`@EnableCaching` and an in-memory `ConcurrentMapCacheManager` holding a `prices` cache) and puts `@Cacheable` on `PriceService.priceFor`, keyed on the SKU so the grid, the cart and checkout all hit the same entries. There is no eviction: prices only change in the nightly ERP import, and the instances restart after it.
B2B storefront on Spring Boot 3.3.5 (Java 21), built with the Spring Boot parent POM (the compiler keeps parameter names, so SpEL can refer to `#buyer` and `#sku`), Spring Data JPA with Hibernate 6.5 on PostgreSQL 16, Spring Security with sessions. Every `/api/storefront/**` request needs a signed-in buyer; `@AuthenticationPrincipal` resolves a `ShopUser(long userId, long companyId, String email)` record that is never null. Every buyer belongs to exactly one company, and a company usually has several buyers. Existing and unchanged: `StorefrontPriceController` (`GET /api/storefront/products/{sku}/price`), `CartService` and `CheckoutService`, which call `priceFor` with the signed-in buyer for each line; the `Product` entity (`sku` id, `listPrice` numeric(12,2), `active`), `ProductRepository.findBySkuAndActiveTrue`, the `ContractPrice` entity (unique on `companyId` + `sku`), `ContractPriceRepository.findByCompanyIdAndSku`, the immutable `PriceView(sku, unitPrice, contractPrice)` record, and `ProductNotFoundException` (`@ResponseStatus(NOT_FOUND)`). Products, list prices and contract prices change only in the nightly ERP import, which runs in the maintenance window with the storefront stopped; the instances start fresh afterwards, so nothing changes under a running instance. The application had no caching before this PR and has no other `CacheManager`. Sizes: about 150 buyer companies and 2,000 active SKUs, so at most 300,000 company and SKU pairs, which the heap is sized for.
Requirements
- `PriceService.priceFor(buyer, sku)` prices one active SKU for the signed-in buyer: the contract price of the buyer's company when that company has one for the SKU, otherwise the product's list price; an unknown or inactive SKU is a 404. These rules do not change. The product page, the cart and checkout all price through this method, and the price it returns is the price the order is charged.
- Contract prices are confidential to each company: a buyer must never be shown, or charged, another company's contract price.
- Cache `priceFor` in memory so repeat lookups of an active SKU skip the database (a 404 need not be cached). A cached answer must be exactly what the uncached lookup would return for that buyer. Entries may live until the application restarts (prices never change under a running instance, see the context).
Files touched
- src/main/java/com/acme/b2b/config/CacheConfig.java
- src/main/java/com/acme/b2b/pricing/PriceService.java
--- src/main/java/com/acme/b2b/config/CacheConfig.java
+package com.acme.b2b.config;
+
+import org.springframework.cache.CacheManager;
+import org.springframework.cache.annotation.EnableCaching;
+import org.springframework.cache.concurrent.ConcurrentMapCacheManager;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+@Configuration
+@EnableCaching
+public class CacheConfig {
+
+ @Bean
+ public CacheManager cacheManager() {
+ return new ConcurrentMapCacheManager("prices");
+ }
+}
+
--- src/main/java/com/acme/b2b/pricing/PriceService.java
import com.acme.b2b.catalog.ProductRepository;
import com.acme.b2b.security.ShopUser;
+import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
}
+ @Cacheable(cacheNames = "prices", key = "#sku")
@Transactional(readOnly = true)
public PriceView priceFor(ShopUser buyer, String sku) {