Series overview
Part 20 of 20100% complete
2026-05-01•30 min read

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

Audit StorePure Policy CoreLand RegistryCitizen RegistryScheme APIAudit StorePure Policy CoreLand RegistryCitizen RegistryScheme APIloadCitizen(citizenId)Result<CitizenFacts, RegistryError>loadLandRecord(citizenId)Result<LandRecord, RegistryError>FarmerSupportPolicy.evaluate(facts, scheme)EligibilityOutcome (Eligible | Ineligible | ManualReview)append(DecisionReport) via IO
Audit StorePure Policy CoreLand RegistryCitizen RegistryScheme APIAudit StorePure Policy CoreLand RegistryCitizen RegistryScheme APIloadCitizen(citizenId)Result<CitizenFacts, RegistryError>loadLandRecord(citizenId)Result<LandRecord, RegistryError>FarmerSupportPolicy.evaluate(facts, scheme)EligibilityOutcome (Eligible | Ineligible | ManualReview)append(DecisionReport) via IO

The pipeline

scheme/DecisionPipeline.java
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

WorkflowAndPipelineTest.java
@Test
void 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

ConcernTypeChapter
Absent evidenceOption<T>5
Expected failuresResult<T, RegistryError>6
Independent validationValidated + map28
Dependent sequencingflatMap9
Effectful chainsResults.andThenK11
AccumulationMonoid, Foldable.combineAll12–13
Expensive conditional factsLazy<T>14
Deferred effectsIO<T>15
ConfigurationReader<PolicyEnvironment, T>16
Workflow transitionsState<ApplicationStatus, A>17
Async lookupsCompletableFuture + virtual threads18

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.

JavaFunctional Programming

Type to search the site.

↑↓ navigate⏎ openPowered by Pagefind