diff --git a/README.md b/README.md
index 5d41519..d79f992 100644
--- a/README.md
+++ b/README.md
@@ -7,7 +7,7 @@ Companion code for a series of posts on [ankurm.com](https://ankurm.com) coverin
| [`time-series/`](./time-series) | Time-Series Data in Java: QuestDB vs InfluxDB vs TimescaleDB | QuestDB 10.0.1, InfluxDB 3 Core 3.12.0, TimescaleDB 2.25.1 (PG17) |
| [`mongodb/`](./mongodb) | Spring Boot 4 with MongoDB: Spring Data MongoDB, Aggregations and Transactions | MongoDB 9.0.2 single-node replica set, Spring Data MongoDB |
| [`elasticsearch/`](./elasticsearch) | Full-Text Search with Spring Data Elasticsearch on Spring Boot 4 | Elasticsearch 9.4.8, Spring Data Elasticsearch 6.1.1 |
-| `distributed-cache/` | Distributed Caching in Spring Boot: Hazelcast vs Infinispan vs Redis | Hazelcast, Infinispan, Redis |
+| [`distributed-cache/`](./distributed-cache) | Distributed Caching in Spring Boot: Hazelcast vs Infinispan vs Redis | Hazelcast 5.5.0 (embedded), Infinispan 16.1.4 (embedded), Redis 8.8.3 (Testcontainers) |
Each module has its own `pom.xml`, a `docker-compose.yml` for its backing services, and a `docs/output/` directory holding the real, re-runnable transcripts that every number quoted in its post traces back to. There are no narrative doc chapters here — the deeper material for each post lives in accordions inside the post itself.
diff --git a/distributed-cache/docs/output/29-hazelcast-cache-evict.txt b/distributed-cache/docs/output/29-hazelcast-cache-evict.txt
new file mode 100644
index 0000000..85217f5
--- /dev/null
+++ b/distributed-cache/docs/output/29-hazelcast-cache-evict.txt
@@ -0,0 +1,3 @@
+backend=hazelcast
+rateFor("USD/EUR") call 1 -> method body ran, callCount 0 -> 1
+evict("USD/EUR") then rateFor("USD/EUR") again -> method body ran again, callCount 1 -> 2
diff --git a/distributed-cache/docs/output/29-hazelcast-cache-hit.txt b/distributed-cache/docs/output/29-hazelcast-cache-hit.txt
new file mode 100644
index 0000000..03a2f66
--- /dev/null
+++ b/distributed-cache/docs/output/29-hazelcast-cache-hit.txt
@@ -0,0 +1,3 @@
+backend=hazelcast
+rateFor("USD/INR") call 1 -> method body ran, callCount 2 -> 3
+rateFor("USD/INR") call 2 -> served from cache, callCount stays 3
diff --git a/distributed-cache/docs/output/29-infinispan-cache-evict.txt b/distributed-cache/docs/output/29-infinispan-cache-evict.txt
new file mode 100644
index 0000000..f7eed6f
--- /dev/null
+++ b/distributed-cache/docs/output/29-infinispan-cache-evict.txt
@@ -0,0 +1,3 @@
+backend=infinispan
+rateFor("USD/EUR") call 1 -> method body ran, callCount 0 -> 1
+evict("USD/EUR") then rateFor("USD/EUR") again -> method body ran again, callCount 1 -> 2
diff --git a/distributed-cache/docs/output/29-infinispan-cache-hit.txt b/distributed-cache/docs/output/29-infinispan-cache-hit.txt
new file mode 100644
index 0000000..610babd
--- /dev/null
+++ b/distributed-cache/docs/output/29-infinispan-cache-hit.txt
@@ -0,0 +1,3 @@
+backend=infinispan
+rateFor("USD/INR") call 1 -> method body ran, callCount 2 -> 3
+rateFor("USD/INR") call 2 -> served from cache, callCount stays 3
diff --git a/distributed-cache/docs/output/29-redis-cache-evict.txt b/distributed-cache/docs/output/29-redis-cache-evict.txt
new file mode 100644
index 0000000..7185dd6
--- /dev/null
+++ b/distributed-cache/docs/output/29-redis-cache-evict.txt
@@ -0,0 +1,3 @@
+backend=redis
+rateFor("USD/EUR") call 1 -> method body ran, callCount 0 -> 1
+evict("USD/EUR") then rateFor("USD/EUR") again -> method body ran again, callCount 1 -> 2
diff --git a/distributed-cache/docs/output/29-redis-cache-hit.txt b/distributed-cache/docs/output/29-redis-cache-hit.txt
new file mode 100644
index 0000000..dd69c4d
--- /dev/null
+++ b/distributed-cache/docs/output/29-redis-cache-hit.txt
@@ -0,0 +1,3 @@
+backend=redis
+rateFor("USD/INR") call 1 -> method body ran, callCount 2 -> 3
+rateFor("USD/INR") call 2 -> served from cache, callCount stays 3
diff --git a/distributed-cache/pom.xml b/distributed-cache/pom.xml
new file mode 100644
index 0000000..ccb5250
--- /dev/null
+++ b/distributed-cache/pom.xml
@@ -0,0 +1,119 @@
+
+
+ 4.0.0
+
+
+ org.springframework.boot
+ spring-boot-starter-parent
+ 4.1.1
+
+
+
+ com.ankurm
+ distributed-cache-demo
+ 1.0.0
+ distributed-cache-demo
+
+
+ 25
+ 2.0.5
+
+
+
+
+
+ org.testcontainers
+ testcontainers-bom
+ ${testcontainers.version}
+ pom
+ import
+
+
+
+
+
+
+ org.springframework.boot
+ spring-boot-starter-cache
+
+
+
+
+ com.hazelcast
+ hazelcast-spring
+
+
+
+
+ io.micrometer
+ micrometer-core
+
+
+ org.infinispan
+ infinispan-spring-boot4-starter-embedded
+
+
+
+
+ org.springframework.boot
+ spring-boot-starter-data-redis
+
+
+
+ org.springframework.boot
+ spring-boot-starter-test
+ test
+
+
+ org.springframework.boot
+ spring-boot-testcontainers
+ test
+
+
+ org.testcontainers
+ testcontainers-junit-jupiter
+ test
+
+
+
+
+
+
+ org.springframework.boot
+ spring-boot-maven-plugin
+
+
+
+ org.apache.maven.plugins
+ maven-surefire-plugin
+
+ false
+
+
+
+
+
diff --git a/distributed-cache/src/main/java/com/ankurm/distributedcache/DistributedCacheDemoApplication.java b/distributed-cache/src/main/java/com/ankurm/distributedcache/DistributedCacheDemoApplication.java
new file mode 100644
index 0000000..435c741
--- /dev/null
+++ b/distributed-cache/src/main/java/com/ankurm/distributedcache/DistributedCacheDemoApplication.java
@@ -0,0 +1,13 @@
+package com.ankurm.distributedcache;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.cache.annotation.EnableCaching;
+
+@SpringBootApplication
+@EnableCaching
+public class DistributedCacheDemoApplication {
+ public static void main(String[] args) {
+ SpringApplication.run(DistributedCacheDemoApplication.class, args);
+ }
+}
diff --git a/distributed-cache/src/main/java/com/ankurm/distributedcache/ExchangeRateLookupService.java b/distributed-cache/src/main/java/com/ankurm/distributedcache/ExchangeRateLookupService.java
new file mode 100644
index 0000000..419e875
--- /dev/null
+++ b/distributed-cache/src/main/java/com/ankurm/distributedcache/ExchangeRateLookupService.java
@@ -0,0 +1,43 @@
+package com.ankurm.distributedcache;
+
+import org.springframework.cache.annotation.CacheEvict;
+import org.springframework.cache.annotation.Cacheable;
+import org.springframework.stereotype.Service;
+
+import java.math.BigDecimal;
+import java.util.concurrent.atomic.AtomicInteger;
+
+/**
+ * Stands in for a slow upstream call (a real rate provider, a legacy
+ * mainframe, whatever). The counter is the whole point: it only goes up
+ * when the method body actually runs, so a test can prove a second call
+ * for the same key was served from the cache without ever calling this
+ * class directly. The @Cacheable/@CacheEvict annotations are the entire
+ * caching story here - which CacheManager bean backs "exchangeRates" is
+ * decided elsewhere, by which Spring profile is active, and this class
+ * neither knows nor cares which one that is.
+ */
+@Service
+public class ExchangeRateLookupService {
+
+ private final AtomicInteger callCount = new AtomicInteger();
+
+ @Cacheable("exchangeRates")
+ public BigDecimal rateFor(String currencyPair) {
+ callCount.incrementAndGet();
+ return switch (currencyPair) {
+ case "USD/INR" -> new BigDecimal("87.42");
+ case "USD/EUR" -> new BigDecimal("0.92");
+ default -> throw new IllegalArgumentException("unknown pair: " + currencyPair);
+ };
+ }
+
+ @CacheEvict("exchangeRates")
+ public void evict(String currencyPair) {
+ // no-op body; the annotation does the work
+ }
+
+ public int callCount() {
+ return callCount.get();
+ }
+}
diff --git a/distributed-cache/src/main/java/com/ankurm/distributedcache/HazelcastCacheConfig.java b/distributed-cache/src/main/java/com/ankurm/distributedcache/HazelcastCacheConfig.java
new file mode 100644
index 0000000..2a96bd0
--- /dev/null
+++ b/distributed-cache/src/main/java/com/ankurm/distributedcache/HazelcastCacheConfig.java
@@ -0,0 +1,46 @@
+package com.ankurm.distributedcache;
+
+import com.hazelcast.config.Config;
+import com.hazelcast.core.Hazelcast;
+import com.hazelcast.core.HazelcastInstance;
+import com.hazelcast.spring.cache.HazelcastCacheManager;
+import org.springframework.cache.CacheManager;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.context.annotation.Primary;
+import org.springframework.context.annotation.Profile;
+
+/**
+ * Active only under the "hazelcast" profile. A single embedded
+ * HazelcastInstance plays the role a real cluster member would - in
+ * production this is the same class, just joining other real members
+ * over the network instead of running alone in the test JVM.
+ * HazelcastCacheManager is Hazelcast's own adapter to Spring's
+ * CacheManager interface; ExchangeRateLookupService never sees it
+ * directly, it only sees the "exchangeRates" cache name.
+ *
+ * {@code @Primary} is required here, not decorative: the Infinispan
+ * starter on the classpath auto-configures its own CacheManager bean
+ * unconditionally (see InfinispanEmbeddedCacheManagerAutoConfiguration),
+ * regardless of which profile is active, so without @Primary Spring
+ * would see two CacheManager beans and this one would not reliably win.
+ */
+@Configuration
+@Profile("hazelcast")
+public class HazelcastCacheConfig {
+
+ @Bean(destroyMethod = "shutdown")
+ public HazelcastInstance hazelcastInstance() {
+ Config config = new Config();
+ config.setClusterName("distributed-cache-demo-" + System.nanoTime());
+ config.getNetworkConfig().getJoin().getMulticastConfig().setEnabled(false);
+ config.getNetworkConfig().getJoin().getAutoDetectionConfig().setEnabled(false);
+ return Hazelcast.newHazelcastInstance(config);
+ }
+
+ @Bean
+ @Primary
+ public CacheManager cacheManager(HazelcastInstance hazelcastInstance) {
+ return new HazelcastCacheManager(hazelcastInstance);
+ }
+}
diff --git a/distributed-cache/src/main/java/com/ankurm/distributedcache/InfinispanCacheConfig.java b/distributed-cache/src/main/java/com/ankurm/distributedcache/InfinispanCacheConfig.java
new file mode 100644
index 0000000..8b50825
--- /dev/null
+++ b/distributed-cache/src/main/java/com/ankurm/distributedcache/InfinispanCacheConfig.java
@@ -0,0 +1,31 @@
+package com.ankurm.distributedcache;
+
+import org.infinispan.configuration.cache.ConfigurationBuilder;
+import org.infinispan.manager.EmbeddedCacheManager;
+import org.infinispan.spring.starter.embedded.InfinispanCacheConfigurer;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+/**
+ * Unlike Hazelcast's HazelcastCacheManager and Spring Data Redis's
+ * RedisCacheManager, Infinispan's embedded CacheManager does not create
+ * an undeclared cache on first use - calling getCache("exchangeRates")
+ * without this configurer throws ISPN000436 ("no matching cache
+ * configuration exists"), confirmed by the real exception a first,
+ * configurer-less version of this class produced. An
+ * InfinispanCacheConfigurer bean is the starter's documented extension
+ * point for declaring a cache programmatically instead of in an
+ * external infinispan.xml. This bean is not @Profile-gated because the
+ * starter's own auto-configuration isn't either - it always creates an
+ * embedded CacheManager regardless of which profile is active, so this
+ * just makes sure the one cache this demo needs exists on it.
+ */
+@Configuration
+public class InfinispanCacheConfig {
+
+ @Bean
+ public InfinispanCacheConfigurer exchangeRatesCacheConfigurer() {
+ return (EmbeddedCacheManager cacheManager) ->
+ cacheManager.defineConfiguration("exchangeRates", new ConfigurationBuilder().build());
+ }
+}
diff --git a/distributed-cache/src/main/java/com/ankurm/distributedcache/RedisCacheConfig.java b/distributed-cache/src/main/java/com/ankurm/distributedcache/RedisCacheConfig.java
new file mode 100644
index 0000000..230c676
--- /dev/null
+++ b/distributed-cache/src/main/java/com/ankurm/distributedcache/RedisCacheConfig.java
@@ -0,0 +1,31 @@
+package com.ankurm.distributedcache;
+
+import org.springframework.cache.CacheManager;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.context.annotation.Primary;
+import org.springframework.context.annotation.Profile;
+import org.springframework.data.redis.cache.RedisCacheManager;
+import org.springframework.data.redis.connection.RedisConnectionFactory;
+
+/**
+ * Active only under the "redis" profile. Unlike the Hazelcast and
+ * Infinispan members, this CacheManager talks to a real out-of-process
+ * Redis server - here a Testcontainers container in tests, any real
+ * Redis instance (standalone or cluster) in production. The cache
+ * survives this application restarting; the embedded members' caches do
+ * not, which is the actual distinction this post is making, not just a
+ * difference in configuration style. @Primary disambiguates from the
+ * Infinispan starter's own unconditional auto-configured CacheManager
+ * bean - see the note on HazelcastCacheConfig for why that's needed.
+ */
+@Configuration
+@Profile("redis")
+public class RedisCacheConfig {
+
+ @Bean
+ @Primary
+ public CacheManager cacheManager(RedisConnectionFactory connectionFactory) {
+ return RedisCacheManager.create(connectionFactory);
+ }
+}
diff --git a/distributed-cache/src/test/java/com/ankurm/distributedcache/CacheHitAndEvictionContractTest.java b/distributed-cache/src/test/java/com/ankurm/distributedcache/CacheHitAndEvictionContractTest.java
new file mode 100644
index 0000000..e46ef32
--- /dev/null
+++ b/distributed-cache/src/test/java/com/ankurm/distributedcache/CacheHitAndEvictionContractTest.java
@@ -0,0 +1,70 @@
+package com.ankurm.distributedcache;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * The same two behaviours, asserted identically, against whichever
+ * CacheManager the active Spring profile wired up. That's deliberate:
+ * the point of the Spring Cache abstraction is that ExchangeRateLookupService
+ * and this test never change when the backend does - only the
+ * subclass's @ActiveProfiles (and, for Redis, the container) changes.
+ *
+ * ExchangeRateLookupService's callCount() is a singleton counter shared
+ * across every @Test method in a subclass's Spring context (JUnit does
+ * not create a fresh context per test method), so each test reads the
+ * counter before and after its own calls and asserts the *delta* rather
+ * than an absolute value - that way test execution order inside one
+ * class can never affect the result.
+ */
+abstract class CacheHitAndEvictionContractTest {
+
+ @Autowired
+ ExchangeRateLookupService service;
+
+ abstract String backendName();
+
+ @Test
+ void secondCallForSameKeyIsServedFromCache() {
+ int before = service.callCount();
+
+ service.rateFor("USD/INR");
+ int afterFirstCall = service.callCount();
+
+ service.rateFor("USD/INR");
+ int afterSecondCall = service.callCount();
+
+ Transcript t = new Transcript("29-" + backendName() + "-cache-hit.txt");
+ t.line("backend=%s", backendName());
+ t.line("rateFor(\"USD/INR\") call 1 -> method body ran, callCount %d -> %d", before, afterFirstCall);
+ t.line("rateFor(\"USD/INR\") call 2 -> served from cache, callCount stays %d", afterSecondCall);
+ t.write();
+
+ assertThat(afterFirstCall).isEqualTo(before + 1);
+ assertThat(afterSecondCall).isEqualTo(afterFirstCall);
+ }
+
+ @Test
+ void evictingAKeyForcesTheNextCallToRecompute() {
+ int before = service.callCount();
+
+ service.rateFor("USD/EUR");
+ int afterFirstCall = service.callCount();
+
+ service.evict("USD/EUR");
+ service.rateFor("USD/EUR");
+ int afterEvictAndRecall = service.callCount();
+
+ Transcript t = new Transcript("29-" + backendName() + "-cache-evict.txt");
+ t.line("backend=%s", backendName());
+ t.line("rateFor(\"USD/EUR\") call 1 -> method body ran, callCount %d -> %d", before, afterFirstCall);
+ t.line("evict(\"USD/EUR\") then rateFor(\"USD/EUR\") again -> method body ran again, callCount %d -> %d",
+ afterFirstCall, afterEvictAndRecall);
+ t.write();
+
+ assertThat(afterFirstCall).isEqualTo(before + 1);
+ assertThat(afterEvictAndRecall).isEqualTo(afterFirstCall + 1);
+ }
+}
diff --git a/distributed-cache/src/test/java/com/ankurm/distributedcache/HazelcastCacheTest.java b/distributed-cache/src/test/java/com/ankurm/distributedcache/HazelcastCacheTest.java
new file mode 100644
index 0000000..45d9bba
--- /dev/null
+++ b/distributed-cache/src/test/java/com/ankurm/distributedcache/HazelcastCacheTest.java
@@ -0,0 +1,13 @@
+package com.ankurm.distributedcache;
+
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.test.context.ActiveProfiles;
+
+@SpringBootTest(classes = DistributedCacheDemoApplication.class)
+@ActiveProfiles("hazelcast")
+class HazelcastCacheTest extends CacheHitAndEvictionContractTest {
+ @Override
+ String backendName() {
+ return "hazelcast";
+ }
+}
diff --git a/distributed-cache/src/test/java/com/ankurm/distributedcache/InfinispanCacheTest.java b/distributed-cache/src/test/java/com/ankurm/distributedcache/InfinispanCacheTest.java
new file mode 100644
index 0000000..124308b
--- /dev/null
+++ b/distributed-cache/src/test/java/com/ankurm/distributedcache/InfinispanCacheTest.java
@@ -0,0 +1,22 @@
+package com.ankurm.distributedcache;
+
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.test.context.ActiveProfiles;
+
+/**
+ * No custom CacheManager bean exists for this profile at all - the
+ * Infinispan starter auto-configures its own SpringEmbeddedCacheManager
+ * unconditionally once it's on the classpath (confirmed by decompiling
+ * InfinispanEmbeddedCacheManagerAutoConfiguration), and nothing under
+ * the "hazelcast" or "redis" profiles is active to out-rank it with
+ * @Primary. The "infinispan" profile name exists only so this test
+ * class's intent reads the same way as its two siblings.
+ */
+@SpringBootTest(classes = DistributedCacheDemoApplication.class)
+@ActiveProfiles("infinispan")
+class InfinispanCacheTest extends CacheHitAndEvictionContractTest {
+ @Override
+ String backendName() {
+ return "infinispan";
+ }
+}
diff --git a/distributed-cache/src/test/java/com/ankurm/distributedcache/RedisCacheTest.java b/distributed-cache/src/test/java/com/ankurm/distributedcache/RedisCacheTest.java
new file mode 100644
index 0000000..25c3d97
--- /dev/null
+++ b/distributed-cache/src/test/java/com/ankurm/distributedcache/RedisCacheTest.java
@@ -0,0 +1,25 @@
+package com.ankurm.distributedcache;
+
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
+import org.springframework.test.context.ActiveProfiles;
+import org.testcontainers.containers.GenericContainer;
+import org.testcontainers.junit.jupiter.Container;
+import org.testcontainers.junit.jupiter.Testcontainers;
+import org.testcontainers.utility.DockerImageName;
+
+@Testcontainers
+@SpringBootTest(classes = DistributedCacheDemoApplication.class)
+@ActiveProfiles("redis")
+class RedisCacheTest extends CacheHitAndEvictionContractTest {
+
+ @Container
+ @ServiceConnection(name = "redis")
+ static GenericContainer> redis =
+ new GenericContainer<>(DockerImageName.parse("redis:8.8.3")).withExposedPorts(6379);
+
+ @Override
+ String backendName() {
+ return "redis";
+ }
+}
diff --git a/distributed-cache/src/test/java/com/ankurm/distributedcache/Transcript.java b/distributed-cache/src/test/java/com/ankurm/distributedcache/Transcript.java
new file mode 100644
index 0000000..66e6e1c
--- /dev/null
+++ b/distributed-cache/src/test/java/com/ankurm/distributedcache/Transcript.java
@@ -0,0 +1,40 @@
+package com.ankurm.distributedcache;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Writes docs/output/NN-*.txt while a test asserts the same numbers, so
+ * every figure quoted in the blog post is backed by an assertion that
+ * fails the build when it stops being true.
+ */
+final class Transcript {
+ private final List lines = new ArrayList<>();
+ private final Path file;
+
+ Transcript(String name) {
+ this.file = Path.of("docs/output/" + name);
+ }
+
+ Transcript line(String s) {
+ lines.add(s);
+ System.out.println(s);
+ return this;
+ }
+
+ Transcript line(String fmt, Object... args) {
+ return line(fmt.formatted(args));
+ }
+
+ void write() {
+ try {
+ Files.createDirectories(file.getParent());
+ Files.writeString(file, String.join("\n", lines) + "\n");
+ } catch (IOException e) {
+ throw new RuntimeException(e);
+ }
+ }
+}