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
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
@Testvoid 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
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; });}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.