Series overview
Part 15 of 2075% complete
2026-04-22•20 min read

IO: effects at the boundary

Everything so far has been pure — values in, values out. But a scheme engine eventually has to do things: append an audit record, publish an event, call a registry. IO<T> is the educational device for keeping those honest: it is a description of an effectful computation that has not run yet. Building an IO does nothing; only unsafeRun() at the outermost edge performs the effects.

The implementation

effect/IO.java
public final class IO<T> {
private final Supplier<T> thunk;
private IO(Supplier<T> thunk) {
this.thunk = thunk;
}
public static <T> IO<T> of(T value) {
return new IO<>(() -> value);
}
public static <T> IO<T> delay(Supplier<T> thunk) {
return new IO<>(Objects.requireNonNull(thunk, "thunk must not be null"));
}
public <R> IO<R> map(Fn1<? super T, ? extends R> mapper) {
return new IO<>(() -> mapper.apply(unsafeRun()));
}
public <R> IO<R> flatMap(Fn1<? super T, ? extends IO<R>> mapper) {
return new IO<>(() -> mapper.apply(unsafeRun()).unsafeRun());
}
public T unsafeRun() {
return thunk.get();
}
}

IO.delay captures a side effect without running it; map and flatMap build larger descriptions. The name unsafeRun is deliberate — it is the moment the pure description meets the impure world, and it should appear exactly once per request, in the outermost layer.

Why bother — the test

core/EffectTest.java
@Test
void ioDoesNotRunUntilUnsafeRun() {
AtomicInteger calls = new AtomicInteger();
IO<Integer> io = IO.delay(calls::incrementAndGet).map(x -> x + 1);
assertThat(calls).hasValue(0); // described, not executed
assertThat(io.unsafeRun()).isEqualTo(2);
assertThat(calls).hasValue(1);
}

The payoff is that the composition of effects — what runs, in what order, on which inputs — is a value you can pass to a test and inspect without executing anything.

Scheme example: the decision pipeline’s boundary

scheme/DecisionPipeline.java
public IO<DecisionReport> auditedEvaluation(String citizenId, AuditStore audit, Clock clock) {
return evaluation(citizenId)
.map(result -> result.fold(
error -> new DecisionReport(
new ManualReview(scheme.value(), citizenId, "registry error"),
RULE_VERSION, clock.instant(), citizenId),
outcome -> new DecisionReport(outcome, RULE_VERSION, clock.instant(), citizenId)))
.map(report -> {
audit.append(report);
return report;
});
}

IO boundary

Pure core

evaluate: CitizenFacts → Outcome

registry lookups

audit.append

event publication

IO boundary

Pure core

evaluate: CitizenFacts → Outcome

registry lookups

audit.append

event publication

The policy evaluation is pure; the audit append is wrapped in the IO description. A test can call auditedEvaluation and assert on the returned IO — or run it against an in-memory AuditStore and assert the trail — without a database, because the effect is data until unsafeRun.

Honest limitations: this IO has no error channel, no cancellation, no async execution, and no retry — it is Supplier with composition and a guilty name. Production JVM effect systems (cats-effect, ZIO) add fiber scheduling, structured concurrency, and resource safety; Part 18 shows the pragmatic middle ground of CompletableFuture and virtual threads. Treat IO here as the teaching device that makes “pure core, effectful edges” a compilable rule rather than a convention.

JavaFunctional Programming

Type to search the site.

↑↓ navigate⏎ openPowered by Pagefind