Series overview
Part 17 of 2085% complete
2026-04-26•20 min read

State: transitions without mutable globals

An application moves through statuses: DRAFT → SUBMITTED → UNDER_REVIEW → APPROVED. The imperative version stores the status in a field and mutates it; the State version treats each transition as a value — a function from old state to (result, new state) that composes with the next transition.

The implementation

effect/State.java
public record State<S, A>(Fn1<S, Tuple2<A, S>> run) {
public A eval(S state) { return run.apply(state).first(); } // the value
public S exec(S state) { return run.apply(state).second(); } // the final state
public <B> State<S, B> map(Fn1<? super A, ? extends B> mapper) {
return new State<>(state -> {
Tuple2<A, S> outcome = run.apply(state);
return new Tuple2<>(mapper.apply(outcome.first()), outcome.second());
});
}
public <B> State<S, B> flatMap(Fn1<? super A, ? extends State<S, B>> mapper) {
return new State<>(state -> {
Tuple2<A, S> outcome = run.apply(state);
return mapper.apply(outcome.first()).apply(outcome.second());
});
}
public static <S, A> State<S, A> of(A value) { ... }
public static <S> State<S, S> get() { ... } // value = current state
public static <S> State<S, S> set(S next) { ... }
public static <S> State<S, S> modify(Fn1<S, S> transition) { ... }
}

flatMap is where the threading happens: run the first step, take its new state, feed it to the next step. eval gives you just the produced value, exec just the final state — you choose what you care about at the edge.

Scheme example: the application workflow

scheme/ApplicationWorkflow.java
public static State<ApplicationStatus, ApplicationStatus> submit() {
return State.modify(status -> switch (status) {
case DRAFT -> ApplicationStatus.SUBMITTED;
default -> throw new IllegalStateException("cannot submit from " + status);
});
}
public static State<ApplicationStatus, ApplicationStatus> submitAndReview() {
return submit().flatMap(submitted -> startReview());
}
WorkflowTest.java
assertThat(ApplicationWorkflow.submitAndReview().exec(ApplicationStatus.DRAFT))
.isEqualTo(ApplicationStatus.UNDER_REVIEW);

Two properties fall out for free. The transition table lives in the switch — approve from DRAFT throws, so illegal sequences are unrepresentable in a composed program. And the whole pipeline is a value: you can test submitAndReview from any starting status without standing up a workflow engine, because “the current state” is just the argument to exec.

When State earns its complexity

Be honest about the cost: State is the least ergonomic type in the library. Java has no for-comprehension, so long flatMap chains read as nested lambdas, and a mutable AtomicReference or a plain field does the same job with less ceremony when the workflow is short and single-threaded. Reach for State when the transition sequence is itself the thing under test — multi-step workflows where you want to assert “from status X, this program lands in status Y” without a container, a database, or a thread — or when you need to replay a transition log against a stored snapshot for audit. For a two-step flow in a singleton service, a guarded setter is fine and nobody should feel guilty about it.

JavaFunctional Programming

Type to search the site.

↑↓ navigate⏎ openPowered by Pagefind