Series overview
Part 14 of 2070% complete
2026-04-21•15 min read

Lazy evaluation and memoization

Some facts are expensive and conditional: a historical-benefit lookup that only one rule in the policy needs, a document OCR call that manual review might never reach. Lazy<T> wraps a supplier, defers it until get(), and memoizes the result — the functional-programming name for this is call-by-need.

The implementation

core/Lazy.java
public final class Lazy<T> {
private Supplier<T> supplier;
private T value;
private boolean evaluated;
private Lazy(Supplier<T> supplier) {
this.supplier = supplier;
}
public static <T> Lazy<T> of(Supplier<T> supplier) {
return new Lazy<>(Objects.requireNonNull(supplier, "supplier must not be null"));
}
public synchronized T get() {
if (!evaluated) {
value = supplier.get();
evaluated = true;
supplier = null;
}
return value;
}
public <R> Lazy<R> map(Fn1<? super T, ? extends R> mapper) {
return Lazy.of(() -> mapper.apply(get()));
}
public <R> Lazy<R> flatMap(Fn1<? super T, Lazy<R>> mapper) {
return Lazy.of(() -> mapper.apply(get()).get());
}
}

Three details matter. synchronized makes the evaluate-once guarantee hold under concurrent readers — the first caller pays the cost, everyone else gets the memoized value. supplier = null releases the captured closure (and whatever it referenced) after evaluation. And map/flatMap build new lazy computations without running anything — laziness composes.

The memoization test

core/LazyTest.java
@Test
void evaluatesTheSupplierAtMostOnce() {
AtomicInteger calls = new AtomicInteger();
Lazy<Integer> lazy = Lazy.of(calls::incrementAndGet);
assertThat(lazy.get()).isEqualTo(1);
assertThat(lazy.get()).isEqualTo(1);
assertThat(calls).hasValue(1);
}
@Test
void mapDefersEvaluationUntilGet() {
AtomicInteger calls = new AtomicInteger();
Lazy<Integer> lazy = Lazy.of(calls::incrementAndGet).map(x -> x * 10);
assertThat(calls).hasValue(0); // nothing ran yet
assertThat(lazy.get()).isEqualTo(10);
}

Scheme example: deferred evidence enrichment

EvidenceEnrichment.java
Lazy<Result<IncomeRecord, RegistryError>> incomeHistory =
Lazy.of(() -> incomeRegistry.load(citizenId)); // expensive historical lookup
// the policy only needs history on the manual-review path — for a clean
// Eligible the registry call never happens
EligibilityOutcome outcome = needsManualReview
? incomeHistory.get().fold(
error -> new ManualReview(scheme.value(), citizenId, "history unavailable"),
record -> evaluateWithHistory(facts, record)) // policy variant, defined by the rules
: FarmerSupportPolicy.evaluate(facts, scheme);

If the rule never asks, the lookup never happens — the “skip work you don’t need” optimization becomes part of the value’s type rather than an if guarding a field.

Trade-off worth stating: Lazy memoizes successfully, meaning exceptions are not cached — a throwing supplier is retried on the next get(). That is usually what you want for transient failures, but a permanently-failing lookup will be re-attempted every time. If you need to remember failure, wrap the computation in Lazy.of(() -> resultOrThrow()) so the memoized value is itself a Result.

JavaFunctional Programming

Type to search the site.

↑↓ navigate⏎ openPowered by Pagefind