read-through
Read-through caching: load a missing key from a source and keep a present answer
ReadThroughCacheFactory supplies caches that ask a source for a missing key once, however many callers want it, and keep each present answer under the capacity and eviction a CachePolicy names.
Rationale
Why. A slow source should be asked once for a key, then not again until the kept value is gone.
Key Concepts
Lookup
Role. apply(key) answers a kept value without asking the source. Otherwise it asks the source and returns the answer. A present answer is kept. An empty answer or a failure is not, so the next lookup asks again.
Pattern. DECORATOR
Policy
Role. CachePolicy names a supplier of capacities, a builder of eviction strategies and the clock that stamps events. The supplier must return a new capacity each time, because each cache keeps its own. The builder receives forget, which removes a key, and the cache's capacity.
Pattern. STRATEGY
How It Works
Guarantees. No public parameter takes null and no public method returns it, declared with contrax's @PreCondition(IsNotNull.class) and @PostCondition(IsNotNull.class). Concurrent lookups of a key share one answer, and an exception from the source reaches each of them.
Thread safety. A cache may be called from any thread. The source and client OBSERVERs must support concurrent calls.
Lane
Purpose. Keeping what a slow source answers.
Alternative. write-through when writes go through the cache as well; ConcurrentHashMap.computeIfAbsent when nothing is ever forgotten.
When to avoid. A source whose latest answer always matters: a kept value is returned until it is evicted.
Usage
Eight concurrent lookups, then a ninth, ask the source once
import java.time.Instant;
import java.util.Collections;
import java.util.Optional;
import java.util.concurrent.Callable;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicInteger;
import software.visionary.cache.Cache;
import software.visionary.cache.capacity.fixed.FixedSize;
import software.visionary.cache.eviction.recency.LeastRecentlyUsed;
import software.visionary.cache.policy.CachePolicy;
import software.visionary.cache.topology.read.through.ReadThroughCacheFactory;
AtomicInteger asked = new AtomicInteger();
Cache<String, Integer> cache = new ReadThroughCacheFactory<String, Integer>(
key -> {
asked.incrementAndGet();
return Optional.of(key.length());
},
new CachePolicy<>(() -> new FixedSize<>(100), LeastRecentlyUsed::new, Instant::now)).get();
Callable<Optional<Integer>> lookup = () -> cache.apply("answer");
try (var callers = Executors.newVirtualThreadPerTaskExecutor()) {
callers.invokeAll(Collections.nCopies(8, lookup));
}
System.out.println(cache.apply("answer"));
System.out.println(asked);
Output
Optional[6]
1An empty answer is not kept, and a present one stays until evicted
Map<String, String> source = new ConcurrentHashMap<>();
Cache<String, String> cache = new ReadThroughCacheFactory<String, String>(
key -> Optional.ofNullable(source.get(key)),
new CachePolicy<>(() -> new FixedSize<>(100), LeastRecentlyUsed::new, Instant::now)).get();
System.out.println(cache.apply("answer"));
source.put("answer", "42");
System.out.println(cache.apply("answer"));
source.put("answer", "43");
System.out.println(cache.apply("answer"));
cache.strategy().evict("answer");
System.out.println(cache.apply("answer"));
Output
Optional.empty
Optional[42]
Optional[42]
Optional[43]An eviction strategy of your own
final class OldestFirst<KEY> implements EvictionStrategy<KEY> {
private final Consumer<KEY> forget;
private final Capacity<KEY> capacity;
private final Queue<KEY> kept = new ArrayDeque<>();
OldestFirst(Consumer<KEY> forget, Capacity<KEY> capacity) {
this.forget = forget;
this.capacity = capacity;
}
@Override public void evict(KEY key) {
forget.accept(key);
}
@Override public void update(CacheEvent<KEY, ?> event) {
if (event instanceof Insert<KEY, ?>) {
kept.add(event.key());
while (capacity.exceeded()) {
evict(kept.element());
}
} else if (event instanceof Evict<KEY, ?>) {
kept.remove(event.key());
}
}
}
Cache<String, Integer> cache = new ReadThroughCacheFactory<String, Integer>(
key -> Optional.of(key.length()),
new CachePolicy<>(() -> new FixedSize<>(2), OldestFirst::new, Instant::now)).get();
cache.add(event -> {
if (event instanceof Evict<String, ?> evicted) { System.out.println("evicted " + evicted.key()); }
});
Stream.of("a", "b", "c").forEach(cache::apply);
Output
evicted aDependencies
software.visionary:cache:2026.10.13at Compilesoftware.visionary.contrax:processor:2026.10.13at Processorsoftware.visionary:junit-closure:2026.09.17at Testing
Views
Drawn from the release. A link in a view opens its source at the release tag.