Capstone: an auditable scheme decision pipeline
The capstone assembles everything into one flow: a request arrives, registries are queried, a pure policy decides, and an audit record is written — with every step’s type stating what it can produce. The point to notice as you read is not any single abstraction; it is that the boundaries are now all typed.
The flow
The pipeline
public final class DecisionPipeline {
private static final String RULE_VERSION = "farmer-support-2026.1";
private final CitizenRegistry citizens; private final LandRegistry land; private final SchemeCode scheme;
public Result<EligibilityOutcome, RegistryError> evaluate(String citizenId) { return citizens.loadCitizen(citizenId) .flatMap(facts -> land.loadLandRecord(citizenId) .map(record -> FarmerSupportPolicy.evaluate(facts, scheme))); }
public IO<Result<EligibilityOutcome, RegistryError>> evaluation(String citizenId) { return IO.delay(() -> evaluate(citizenId)); }
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; }); }}Trace the types: evaluate is pure description — a Result that is either a decision or a named registry error. evaluation lifts it into IO, deferring the actual registry calls. auditedEvaluation composes three steps into one IO<DecisionReport>: run the lookups, convert any outcome (including Failure) into an auditable DecisionReport — a registry error routes to ManualReview rather than vanishing — then append it to the store. Nothing touches the network or the audit table until the controller calls unsafeRun().
The test that proves the boundary
@Testvoid pipelineEvaluatesAndAudits() { DecisionPipeline pipeline = new DecisionPipeline( citizenId -> Result.success(new CitizenFacts(citizenId, true, 100_000, true)), citizenId -> Result.success(new LandRecord(citizenId, LandCategory.RAINFED, 1.5)), new SchemeCode("FARMER-SUPPORT"));
List<DecisionReport> auditTrail = new ArrayList<>(); Clock clock = Clock.fixed(Instant.parse("2026-09-24T10:00:00Z"), ZoneOffset.UTC);
DecisionReport report = pipeline .auditedEvaluation("C-7", auditTrail::add, clock) .unsafeRun();
assertThat(report.outcome()).isEqualTo(new Eligible("FARMER-SUPPORT", "C-7")); assertThat(report.ruleVersion()).isEqualTo("farmer-support-2026.1"); assertThat(auditTrail).hasSize(1);}No mocks, no containers, no database — the “registries” are lambdas returning Result, the audit store is a List::add, the clock is fixed. That is what the whole series was building toward: a decision pipeline whose every effect is a value, so the entire flow is testable in a unit test that runs in milliseconds.
Where each piece landed
| Concern | Type | Chapter |
|---|---|---|
| Absent evidence | Option<T> | 5 |
| Expected failures | Result<T, RegistryError> | 6 |
| Independent validation | Validated + map2 | 8 |
| Dependent sequencing | flatMap | 9 |
| Effectful chains | Results.andThenK | 11 |
| Accumulation | Monoid, Foldable.combineAll | 12–13 |
| Expensive conditional facts | Lazy<T> | 14 |
| Deferred effects | IO<T> | 15 |
| Configuration | Reader<PolicyEnvironment, T> | 16 |
| Workflow transitions | State<ApplicationStatus, A> | 17 |
| Async lookups | CompletableFuture + virtual threads | 18 |
What this series deliberately left out
Property-based law testing (jqwik), a Traversable over real collections, optics/lenses for nested updates, and a production effect runtime — each is a real tool and each would have doubled the series. The library at this point is ~700 lines; it is not a replacement for vavr or cats-effect, and was never meant to be. What it is: a working demonstration that Java 21’s records, sealed types, and pattern matching are enough to make functional architecture concrete — and a set of decision rules (which type for which failure, when to flatMap versus map2, where unsafeRun lives) that transfer directly to whichever effect library or framework you actually ship.