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