Beginner
This beginner tutorial introduces Finite State Machine fundamentals through 25 annotated code examples grounded in the PurchaseOrder aggregate from the procurement-platform-be backend. You will learn how to model states as sealed types, encode transitions as pure functions, enforce guard conditions, and reject invalid transitions at the type level or with explicit errors.
Domain scope note: The beginner
POStatecovers the core approval-issuance lifecycle (Draft → AwaitingApproval → Approved → Issued → Acknowledged → Closed/Cancelled/Disputed). States from the full domain spec —PartiallyReceived,Received,Invoiced, andPaid— are intentionally deferred to intermediate and advanced levels, where multi-machine coordination and extended lifecycle patterns are introduced.
What Is a Finite State Machine? (Examples 1-5)
Example 1: States as a Sealed Type
A PurchaseOrder (PO) begins as a Draft, moves through approval, gets issued to a supplier, and eventually closes or is cancelled. The first FSM decision is how to represent the state set — a sealed union type prevents code from inventing states that do not exist in the model.
stateDiagram-v2
[*] --> Draft
Draft --> AwaitingApproval: submit
AwaitingApproval --> Approved: approve
AwaitingApproval --> Cancelled: reject
Approved --> Issued: issue
Issued --> Acknowledged: acknowledge
Acknowledged --> Closed: close
Draft --> Cancelled: cancel
Approved --> Cancelled: cancel
classDef draft fill:#0173B2,stroke:#000,color:#fff
classDef waiting fill:#DE8F05,stroke:#000,color:#000
classDef approved fill:#029E73,stroke:#000,color:#fff
classDef terminal fill:#CA9161,stroke:#000,color:#fff
class Draft draft
class AwaitingApproval waiting
class Approved approved
class Issued,Acknowledged approved
class Closed,Cancelled terminal
// => Java: enum models the sealed state set — the compiler rejects any value
// => not in the enum body, providing the same safety as a sealed union type
public enum POState {
DRAFT, // => PO created, not yet submitted for approval
AWAITING_APPROVAL, // => Submitted; waiting for manager decision
APPROVED, // => Manager approved; ready to issue to supplier
ISSUED, // => Sent to supplier; lines now immutable
ACKNOWLEDGED, // => Supplier confirmed receipt of PO
CLOSED, // => PO fully complete — terminal state
CANCELLED, // => Abandoned before payment — terminal state
DISPUTED // => Discrepancy detected; resolution required
}
// => Pure helper: decide whether a state allows no further transitions
// => No side effects — result determined solely by the input value
public static boolean isTerminal(POState state) {
// => Two terminal states; switch exhausts all enum constants at compile time
return state == POState.CLOSED || state == POState.CANCELLED;
}
// => Usage
POState initial = POState.DRAFT; // => Type-safe: only enum constants compile
System.out.println(isTerminal(initial)); // => Output: false
System.out.println(isTerminal(POState.CLOSED)); // => Output: true
// => POState bad = "Pending"; // => Compile error: String is not POStateKey Takeaway: Sealed union types turn the FSM state set into a compile-time contract — invalid states are errors, not bugs found in production.
Why It Matters: Every application has implicit states. Making them explicit with a sealed type eliminates an entire class of bugs: you cannot accidentally introduce "pending" vs "Pending" inconsistency, and the IDE guides you with exhaustive completion. For procurement workflows where an incorrect state might trigger a payment to a cancelled order, explicit sealed types provide low-cost, high-value safety.
Example 2: The Minimal FSM Record
The machine is the state plus the transition function. This example models the simplest possible PO FSM as a plain record holding the current state.
// => Java: an immutable record (Java 16+) holds all PO fields
// => No setters — every field is final; mutation produces a new object
public record PurchaseOrder(
String id, // => Immutable identifier, format "po_<uuid>"
double totalAmount, // => Monetary total in USD; guards compare against thresholds
POState state // => Current FSM state — the machine's single mutable concern
) {
// => Compact constructor validates at construction time
public PurchaseOrder {
// => Guard: id must not be blank
if (id == null || id.isBlank())
throw new IllegalArgumentException("PO id must not be blank");
// => Guard: totalAmount must be non-negative
if (totalAmount < 0)
throw new IllegalArgumentException("totalAmount must be >= 0");
}
}
// => Factory: pure function — no side effects, always returns Draft state
public static PurchaseOrder createPO(String id, double totalAmount) {
// => All POs begin in DRAFT — FSM invariant enforced here
return new PurchaseOrder(id, totalAmount, POState.DRAFT);
}
// => Usage
PurchaseOrder po = createPO("po_abc123", 500);
System.out.println(po.state()); // => Output: DRAFT
System.out.println(po.totalAmount()); // => Output: 500.0
// => po.state = APPROVED; // => Compile error: records have no settersKey Takeaway: Keeping state in an immutable record and behaviour in separate functions is the functional FSM pattern — it makes transitions testable in isolation.
Why It Matters: When state lives in a mutable class field, tests must instantiate the whole class to check one transition. When state is a plain record, tests create the minimal record and call the transition function — no constructor, no teardown, no mocks. This separation scales to large state machines without increasing test friction.
Example 3: The Transition Table
A transition table maps (currentState, event) → nextState. Expressing it as data rather than nested if/switch statements makes the full machine inspectable and serialisable.
stateDiagram-v2
[*] --> Draft
Draft --> AwaitingApproval: submit
Draft --> Cancelled: cancel
AwaitingApproval --> Approved: approve
AwaitingApproval --> Cancelled: reject / cancel
Approved --> Issued: issue
Approved --> Cancelled: cancel
Approved --> Disputed: dispute
Issued --> Acknowledged: acknowledge
Issued --> Cancelled: cancel
Issued --> Disputed: dispute
Acknowledged --> Closed: close
Acknowledged --> Cancelled: cancel
Acknowledged --> Disputed: dispute
Disputed --> Approved: approve
Disputed --> Cancelled: cancel
classDef start fill:#0173B2,stroke:#000,color:#fff
classDef waiting fill:#DE8F05,stroke:#000,color:#000
classDef active fill:#029E73,stroke:#000,color:#fff
classDef terminal fill:#CA9161,stroke:#000,color:#fff
classDef dispute fill:#CC78BC,stroke:#000,color:#fff
class Draft start
class AwaitingApproval waiting
class Approved,Issued,Acknowledged active
class Closed,Cancelled terminal
class Disputed dispute
// => Java: enum models every trigger that can change PO state
// => Using an enum prevents typos and exhausts all events in switch expressions
public enum POEvent {
SUBMIT, // => Buyer sends PO for approval
APPROVE, // => Manager approves the PO
REJECT, // => Manager rejects — PO transitions to CANCELLED
ISSUE, // => Finance issues PO to supplier
ACKNOWLEDGE, // => Supplier acknowledges receipt
CLOSE, // => PO fully settled
CANCEL, // => Abandon PO at any pre-terminal state
DISPUTE // => Flag discrepancy between PO and receipt
}
// => Transition table as a nested immutable map: state → (event → nextState)
// => Map.of() and Map.copyOf() produce unmodifiable maps — no accidental mutation
import java.util.Map;
import java.util.Optional;
private static final Map<POState, Map<POEvent, POState>> TRANSITIONS = Map.of(
POState.DRAFT, Map.of(POEvent.SUBMIT, POState.AWAITING_APPROVAL,
POEvent.CANCEL, POState.CANCELLED),
// => Draft only allows submit (→ awaiting) or cancel (→ cancelled)
POState.AWAITING_APPROVAL, Map.of(POEvent.APPROVE, POState.APPROVED,
POEvent.REJECT, POState.CANCELLED,
POEvent.CANCEL, POState.CANCELLED),
// => Rejection and cancellation both lead to the same terminal state
POState.APPROVED, Map.of(POEvent.ISSUE, POState.ISSUED,
POEvent.CANCEL, POState.CANCELLED,
POEvent.DISPUTE, POState.DISPUTED),
POState.ISSUED, Map.of(POEvent.ACKNOWLEDGE, POState.ACKNOWLEDGED,
POEvent.CANCEL, POState.CANCELLED,
POEvent.DISPUTE, POState.DISPUTED),
POState.ACKNOWLEDGED, Map.of(POEvent.CLOSE, POState.CLOSED,
POEvent.CANCEL, POState.CANCELLED,
POEvent.DISPUTE, POState.DISPUTED),
POState.DISPUTED, Map.of(POEvent.APPROVE, POState.APPROVED,
POEvent.CANCEL, POState.CANCELLED)
// => CLOSED and CANCELLED have no entries — terminal states allow no transitions
);
// => Pure lookup: returns empty Optional when transition is forbidden
// => Optional makes the absence of a transition explicit at the call site
public static Optional<POState> nextState(POState current, POEvent event) {
return Optional.ofNullable(
TRANSITIONS.getOrDefault(current, Map.of()).get(event)
); // => getOrDefault handles terminal states (no entry in outer map)
}
System.out.println(nextState(POState.DRAFT, POEvent.SUBMIT));
// => Output: Optional[AWAITING_APPROVAL]
System.out.println(nextState(POState.DRAFT, POEvent.APPROVE));
// => Output: Optional.empty (invalid — Draft cannot be approved directly)
System.out.println(nextState(POState.CLOSED, POEvent.CANCEL));
// => Output: Optional.empty (terminal — no further transitions)Key Takeaway: A data-driven transition table decouples the FSM structure from the execution logic — you can print, diff, or migrate it without touching the interpreter.
Why It Matters: Hard-coded switch statements in large state machines become maintenance liabilities. When a new transition is needed, the developer must scan the entire switch to find the right case. A transition table is a single data structure — add one entry, and the interpreter picks it up automatically. This also enables tooling: you can generate state diagrams directly from the table.
Example 4: The Pure Transition Function
Wrapping the table lookup in a function that returns a Result type makes the FSM's success/failure contract explicit without throwing exceptions.
// => Java: sealed interface models the Result sum type (Java 17+)
// => Either Ok<T> (success) or Err (failure) — no other constructors possible
public sealed interface Result<T> permits Result.Ok, Result.Err {
// => Ok variant carries the success value
record Ok<T>(T value) implements Result<T> {}
// => Err variant carries an error message — never throws
record Err<T>(String error) implements Result<T> {}
}
// => Pure transition function: (PO, event) → Result<PO>
// => Never mutates its input; always returns a new record or an Err
public static Result<PurchaseOrder> transition(PurchaseOrder po, POEvent event) {
// => Look up next state in the transition table
var next = nextState(po.state(), event);
if (next.isEmpty()) {
// => Transition not found: return descriptive error, do not throw
// => Caller must inspect the Result before using the value
return new Result.Err<>(
"Invalid transition: " + po.state() + " --" + event + "--> (no such transition)"
);
}
// => Valid transition: build a new PO record with the updated state
// => Java record's with() is not built-in; use constructor with spread semantics
var updated = new PurchaseOrder(po.id(), po.totalAmount(), next.get());
// => All other fields preserved; only state changes
return new Result.Ok<>(updated);
}
// => Happy path
var po = createPO("po_abc123", 500);
var r1 = transition(po, POEvent.SUBMIT);
// => Pattern match on the sealed Result type (Java 21 switch expression)
switch (r1) {
case Result.Ok<PurchaseOrder> ok -> System.out.println(ok.value().state());
// => Output: AWAITING_APPROVAL
case Result.Err<PurchaseOrder> err -> System.out.println(err.error());
}
// => Invalid transition
var r2 = transition(po, POEvent.CLOSE);
switch (r2) {
case Result.Ok<PurchaseOrder> ok -> System.out.println(ok.value().state());
case Result.Err<PurchaseOrder> err -> System.out.println(err.error());
// => Output: Invalid transition: DRAFT --CLOSE--> (no such transition)
}Key Takeaway: Returning Result instead of throwing makes invalid transitions an explicit, typed outcome — callers must handle both paths, which prevents silent state corruption.
Why It Matters: Exception-based control flow means the compiler cannot force callers to handle the error case. A Result type forces the caller to branch on ok, making the error path as visible as the success path. For a procurement platform where a failed state transition might silently leave a PO in an inconsistent state, this explicitness is non-negotiable.
Example 5: Exhaustiveness Checking with a Switch
Pattern-matching on the state with an exhaustive switch guarantees every state has been handled — the TypeScript compiler catches missing cases at compile time.
// => Java: exhaustive switch expression on an enum (Java 14+)
// => The compiler reports an error if any enum constant is missing from the arms
// => This is the Java equivalent of TypeScript's `never`-default trick
public static String stateLabel(POState state) {
// => switch expression (not statement) — every arm must return a value
return switch (state) {
case DRAFT -> "Draft — pending submission";
// => PO created, not yet submitted for approval
case AWAITING_APPROVAL -> "Awaiting Approval";
// => Submitted; waiting for manager decision
case APPROVED -> "Approved — ready to issue";
// => Manager approved; finance may now issue to supplier
case ISSUED -> "Issued to Supplier";
// => PO sent to supplier; lines now immutable
case ACKNOWLEDGED -> "Acknowledged by Supplier";
// => Supplier confirmed receipt of the PO
case CLOSED -> "Closed";
// => Terminal: PO fully complete and settled
case CANCELLED -> "Cancelled";
// => Terminal: PO abandoned before payment
case DISPUTED -> "Disputed — resolution pending";
// => Discrepancy flagged between PO and receipt/invoice
// => No default branch: if a new constant is added to POState enum,
// => this switch expression becomes a compile error until updated
};
}
System.out.println(stateLabel(POState.DRAFT));
// => Output: Draft — pending submission
System.out.println(stateLabel(POState.ISSUED));
// => Output: Issued to SupplierKey Takeaway: Exhaustive switch with a never default turns adding a new state without updating all handlers from a silent runtime bug into a compile-time error.
Why It Matters: In long-lived codebases, state machines grow. Without exhaustiveness checking, adding "PartiallyReceived" to POState means every switch on state silently falls through to the default — wrong labels, wrong behaviour, wrong UI rendering. The never trick costs one line and prevents the whole category of silent regression.
Guards and Approval Levels (Examples 6-11)
Example 6: Approval-Level Guard
The P2P domain defines approval thresholds: POs ≤ 10k need L2, and > $10k need L3. A guard function encodes this rule as a predicate that the transition checks before allowing the approve event.
stateDiagram-v2
[*] --> AwaitingApproval
AwaitingApproval --> Approved: approve [canApprove = true]
AwaitingApproval --> Rejected: approve [canApprove = false]
note right of AwaitingApproval
L1: amount ≤ $1k
L2: amount ≤ $10k
L3: amount > $10k
end note
classDef waiting fill:#DE8F05,stroke:#000,color:#000
classDef approved fill:#029E73,stroke:#000,color:#fff
classDef rejected fill:#CA9161,stroke:#000,color:#fff
class AwaitingApproval waiting
class Approved approved
class Rejected rejected
// => Java: enum for ApprovalLevel gives a fixed, ordered set of authority tiers
// => Enum ordinal() reflects the natural ranking: L1 < L2 < L3
public enum ApprovalLevel {
L1, // => Line manager — authorises POs up to $1,000
L2, // => Department head — authorises POs up to $10,000
L3 // => CFO / finance committee — authorises POs above $10,000
}
// => Pure function: derive required approval level from PO monetary total
// => No side effects — result depends only on the totalAmount argument
public static ApprovalLevel requiredApprovalLevel(double totalAmount) {
if (totalAmount <= 1_000) return ApprovalLevel.L1;
// => Up to $1,000: line manager is sufficient
if (totalAmount <= 10_000) return ApprovalLevel.L2;
// => $1,001–$10,000: department head required
return ApprovalLevel.L3;
// => Above $10,000: CFO or finance committee must approve
}
// => Guard: is the actor's level sufficient to approve this PO?
// => Returns true when actorLevel >= required level (by enum ordinal)
public static boolean canApprove(PurchaseOrder po, ApprovalLevel actorLevel) {
var required = requiredApprovalLevel(po.totalAmount());
// => ordinal() returns 0, 1, or 2 — higher value means higher authority
return actorLevel.ordinal() >= required.ordinal();
// => L3.ordinal() == 2, L2.ordinal() == 1, so L3 satisfies any requirement
}
var lowPO = createPO("po_low", 800.0); // => $800: needs L1
var highPO = createPO("po_high", 15_000.0); // => $15,000: needs L3
System.out.println(canApprove(lowPO, ApprovalLevel.L1));
// => Output: true ($800 <= $1k, L1 sufficient)
System.out.println(canApprove(highPO, ApprovalLevel.L2));
// => Output: false ($15k > $10k, L2 insufficient)
System.out.println(canApprove(highPO, ApprovalLevel.L3));
// => Output: true (L3 is the highest authority tier)Key Takeaway: Guards are pure predicates that sit between an event and a transition — they encode business rules without coupling them to state storage.
Why It Matters: Encoding approval thresholds directly in the guard function means the rule lives in one place. If the CFO decides L2 approval should cover up to $20k, one number changes. Without guards, the threshold check might be duplicated across the controller, the service, and the UI — three places to update and three places to desync.
Example 7: Guarded Transition Function
Composing the guard with the transition function produces a single approve operation that enforces both the FSM state check and the business-rule guard.
// => Java: ApprovableContext record bundles PO + actor level into one parameter
// => Using a record instead of two separate parameters keeps the method signature stable
// => when additional context fields (e.g. actorId) are added later
public record ApprovableContext(PurchaseOrder po, ApprovalLevel actorLevel) {}
// => Guarded approve: enforces FSM state check AND business-rule guard
// => Returns Result<PurchaseOrder> — never throws; both failure modes are typed
public static Result<PurchaseOrder> approvePO(ApprovableContext ctx) {
var po = ctx.po();
var actorLevel = ctx.actorLevel();
// => Guard 1: FSM structural check — PO must be in AWAITING_APPROVAL
// => Any other state means the approval event is not in the transition table
if (po.state() != POState.AWAITING_APPROVAL) {
return new Result.Err<>(
"Cannot approve PO in state: " + po.state()
);
}
// => Guard 2: business-rule check — actor must have sufficient authority
// => This is a domain invariant, not an FSM error; separated for testability
if (!canApprove(po, actorLevel)) {
var required = requiredApprovalLevel(po.totalAmount());
return new Result.Err<>(
"Actor level " + actorLevel + " cannot approve $" + po.totalAmount()
+ " PO (requires " + required + ")"
);
}
// => Both guards pass: build new PO record with state APPROVED
// => All other fields (id, totalAmount) are preserved unchanged
var approved = new PurchaseOrder(po.id(), po.totalAmount(), POState.APPROVED);
return new Result.Ok<>(approved);
}
// => Build an AwaitingApproval PO by constructing directly (simulates prior submit)
var po = new PurchaseOrder("po_001", 12_000.0, POState.AWAITING_APPROVAL);
var r1 = approvePO(new ApprovableContext(po, ApprovalLevel.L2));
// => L2 insufficient for $12k PO (requires L3)
var r2 = approvePO(new ApprovableContext(po, ApprovalLevel.L3));
// => L3 sufficient — should succeed
switch (r1) {
case Result.Err<PurchaseOrder> err -> System.out.println(err.error());
// => Output: Actor level L2 cannot approve $12000.0 PO (requires L3)
case Result.Ok<PurchaseOrder> ok -> System.out.println(ok.value().state());
}
switch (r2) {
case Result.Ok<PurchaseOrder> ok -> System.out.println(ok.value().state());
// => Output: APPROVED
case Result.Err<PurchaseOrder> err -> System.out.println(err.error());
}Key Takeaway: Layering domain guards on top of FSM state checks produces a single, testable function that enforces both structural and business invariants.
Why It Matters: Separating the FSM check (state === "AwaitingApproval") from the business guard (canApprove) keeps each predicate focused. You can unit-test canApprove with just numbers and approval levels — no FSM setup needed. You can test the state check independently. The composed approvePO function then has exactly two failure modes, each tested by a single case.
Example 8: Line-Item Guard
A PO cannot move past Approved without at least one line item. This is a structural invariant, not an approval-level rule — it lives in its own guard.
// => POLine record: immutable value object for one product line
// => Java record auto-generates constructor, getters, equals, hashCode, toString
public record POLine(
String skuCode, // => Product SKU, format "ELC-XXXXX"
int quantity, // => Must be > 0 (domain invariant enforced by guard)
double unitPrice // => Price per unit in USD
) {}
// => PurchaseOrderWithLines: PO record extended with an ordered list of lines
// => Using List<POLine> — List.copyOf ensures the field is unmodifiable
public record PurchaseOrderWithLines(
String id,
double totalAmount,
POState state,
List<POLine> lines // => Immutable snapshot: List.copyOf at construction time
) {
// => Compact canonical constructor: enforce immutability on lines
public PurchaseOrderWithLines {
lines = List.copyOf(lines);
// => List.copyOf: returns unmodifiable view — mutations throw UnsupportedOperationException
}
}
// => Guard function: checks the structural invariant "at least one line with positive quantity"
// => Returns Optional.empty() if the guard passes, Optional.of(errorMessage) if it fails
public static Optional<String> guardAtLeastOneLine(PurchaseOrderWithLines po) {
if (po.lines().isEmpty()) {
// => Domain invariant: a PO with no items is commercially meaningless
return Optional.of("Cannot issue PO: no line items (add at least one product line)");
}
boolean anyZeroQuantity = po.lines().stream()
.anyMatch(l -> l.quantity() <= 0);
// => Stream.anyMatch: short-circuits on first match — O(1) best case
if (anyZeroQuantity) {
return Optional.of("Cannot issue PO: all line quantities must be > 0");
// => Per-line invariant: zero or negative quantity has no procurement meaning
}
return Optional.empty(); // => Guard passes: no structural violation found
}
// => Guarded issue transition: returns Result wrapping new PO or error
public static Result<PurchaseOrderWithLines> issuePO(PurchaseOrderWithLines po) {
if (po.state() != POState.APPROVED) {
// => Pre-condition: can only issue from Approved state
return Result.failure("Cannot issue PO in state " + po.state());
}
Optional<String> lineError = guardAtLeastOneLine(po);
// => Run structural guard before committing the transition
if (lineError.isPresent()) {
return Result.failure(lineError.get());
// => Guard failed: propagate the specific error to the caller
}
PurchaseOrderWithLines issued = new PurchaseOrderWithLines(
po.id(), po.totalAmount(), POState.ISSUED, po.lines()
);
// => New record: state promoted to ISSUED, all other fields unchanged
return Result.success(issued);
// => Transition complete: lines are now immutable per domain rules
}
// => Test: PO with no lines cannot be issued
PurchaseOrderWithLines poNoLines = new PurchaseOrderWithLines(
"po_002", 500.0, POState.APPROVED, List.of()
// => List.of(): creates empty unmodifiable list — idiomatic Java
);
Result<PurchaseOrderWithLines> r = issuePO(poNoLines);
System.out.println(r.isSuccess()); // => Output: false
System.out.println(r.error());
// => Output: Cannot issue PO: no line items (add at least one product line)Key Takeaway: Structural invariants (must have lines) belong in dedicated guard functions, not buried in the transition table — each guard tests one rule and one rule only.
Why It Matters: A PO with zero lines is not a business error in the same category as an underpowered approver — it is a data completeness violation. Keeping them in separate guard functions makes the code self-documenting: guardAtLeastOneLine is searchable, testable, and reviewable independently of the approval logic.
Example 9: Immutable Lines After Issue
Once a PO is Issued, its lines must not change. Enforcing this at the state level — reject any line mutation when state === "Issued" — is a core FSM invariant.
stateDiagram-v2
Draft --> Approved: (line items mutable)
Approved --> Issued: issue
note right of Issued
Lines become IMMUTABLE
addLine() → Error
from this point forward
end note
Issued --> Acknowledged: acknowledge
Acknowledged --> Closed: close
classDef mutable fill:#0173B2,stroke:#000,color:#fff
classDef immutable fill:#029E73,stroke:#000,color:#fff
classDef terminal fill:#CA9161,stroke:#000,color:#fff
class Draft,Approved mutable
class Issued,Acknowledged immutable
class Closed terminal
// => Set of states after which lines become immutable — a legal commitment exists
// => EnumSet: highly efficient set for enum constants, O(1) contains()
import java.util.EnumSet;
import java.util.Set;
private static final Set<POState> IMMUTABLE_STATES =
EnumSet.of(POState.ISSUED, POState.ACKNOWLEDGED, POState.CLOSED);
// => EnumSet.of: creates a compact bit-vector set for enum constants
// => addLine: attempts to append a POLine to a PO
// => Returns Result.failure if state forbids mutation, Result.success with new PO otherwise
public static Result<PurchaseOrderWithLines> addLine(
PurchaseOrderWithLines po, POLine line) {
if (IMMUTABLE_STATES.contains(po.state())) {
// => Guard: once Issued or beyond, lines are a legal supplier commitment
return Result.failure(
"Cannot modify lines: PO is " + po.state() + " (lines immutable after issue)"
);
// => Error message names the current state — aids debugging and audit logging
}
if (line.quantity() <= 0) {
// => Per-line invariant: quantity must be positive to have procurement meaning
return Result.failure("Line quantity must be > 0");
}
// => Build new line list: prepend existing lines, append new line
List<POLine> newLines = new ArrayList<>(po.lines());
newLines.add(line);
// => ArrayList copy + add: O(n) but acceptable for PO line counts (typically < 100)
PurchaseOrderWithLines updated = new PurchaseOrderWithLines(
po.id(), po.totalAmount(), po.state(), List.copyOf(newLines)
// => List.copyOf: wraps in unmodifiable list — contract preserved
);
return Result.success(updated);
// => New record returned: original po reference unchanged (immutable update)
}
// => Test: cannot add a line to an already-issued PO
PurchaseOrderWithLines issuedPO = new PurchaseOrderWithLines(
"po_003", 800.0, POState.ISSUED,
List.of(new POLine("ELC-0042", 10, 80.0))
// => List.of: creates unmodifiable singleton list for the existing line
);
POLine newLine = new POLine("ELC-0099", 5, 50.0);
Result<PurchaseOrderWithLines> r = addLine(issuedPO, newLine);
System.out.println(r.isSuccess()); // => Output: false
System.out.println(r.error());
// => Output: Cannot modify lines: PO is ISSUED (lines immutable after issue)Key Takeaway: FSM state is not just routing logic — it also gates data mutations. Tying mutation guards to state makes the immutability rule automatic and auditable.
Why It Matters: In a procurement system, an issued PO is a legal commitment. If a buyer can add a 5k PO, the approval is meaningless. Tying the line-item mutation guard to the FSM state ensures the commitment model is enforced structurally, not by convention or code review.
Example 10: Cancel From Any Pre-Paid State
The same PO machine in Python illustrates how the "cancel from any pre-paid state" rule reads cleanly with a set of allowed source states.
stateDiagram-v2
Draft --> Cancelled: cancel ✓
AwaitingApproval --> Cancelled: cancel ✓
Approved --> Cancelled: cancel ✓
Issued --> Cancelled: cancel ✓
Acknowledged --> Cancelled: cancel ✓
Closed --> Cancelled: cancel ✗ (terminal)
note right of Cancelled
Terminal state — no further
transitions possible
end note
classDef cancellable fill:#0173B2,stroke:#000,color:#fff
classDef terminal fill:#CA9161,stroke:#000,color:#fff
classDef locked fill:#CC78BC,stroke:#000,color:#fff
class Draft,AwaitingApproval,Approved,Issued,Acknowledged cancellable
class Cancelled,Closed terminal
// => EnumSet of cancellable states: explicit allow-list (safer than deny-list)
// => Any state NOT in this set is non-cancellable by default — conservative choice
import java.util.EnumSet;
import java.util.Set;
private static final Set<POState> CANCELLABLE = EnumSet.of(
POState.DRAFT, POState.AWAITING_APPROVAL, POState.APPROVED,
POState.ISSUED, POState.ACKNOWLEDGED, POState.DISPUTED
// => Closed and Cancelled are terminal — omitting them defaults to non-cancellable
);
// => EnumSet: bit-vector backed, O(1) contains() — ideal for enum membership tests
// => cancelPO: pure function — returns Optional.of(newPO) or Optional.empty() + error
// => Using a simple record to return both (Optional<PO>, Optional<String>) pairs
public record CancelResult(
java.util.Optional<PurchaseOrder> po,
java.util.Optional<String> error
) {
// => Convenience factories for the two cases
static CancelResult success(PurchaseOrder po) {
return new CancelResult(java.util.Optional.of(po), java.util.Optional.empty());
}
static CancelResult failure(String error) {
return new CancelResult(java.util.Optional.empty(), java.util.Optional.of(error));
}
}
public static CancelResult cancelPO(PurchaseOrder po) {
if (!CANCELLABLE.contains(po.state())) {
// => State not in allow-list: cancel forbidden
// => Conservative default: new states added to the enum are non-cancellable
return CancelResult.failure("Cannot cancel PO in state '" + po.state() + "'");
}
PurchaseOrder cancelled = new PurchaseOrder(
po.id(), po.totalAmount(), POState.CANCELLED
// => New record: only state changes; id and totalAmount preserved
);
return CancelResult.success(cancelled);
// => dataclasses.replace equivalent in Java: constructor call with changed field
}
// => Test: cancellable state (APPROVED → CANCELLED)
PurchaseOrder approved = new PurchaseOrder("po_xyz", 1500.0, POState.APPROVED);
CancelResult r1 = cancelPO(approved);
r1.po().ifPresent(p -> System.out.println(p.state())); // => Output: CANCELLED
// => Test: terminal state (CLOSED → error)
PurchaseOrder closed = new PurchaseOrder("po_xyz", 1500.0, POState.CLOSED);
CancelResult r2 = cancelPO(closed);
r2.error().ifPresent(System.out::println);
// => Output: Cannot cancel PO in state 'CLOSED'Key Takeaway: An explicit allow-list (CANCELLABLE) is safer than a deny-list — new states default to non-cancellable, which is the conservative choice for a procurement platform.
Why It Matters: Deny-lists require the developer to remember to add every non-cancellable state. Allow-lists require only that the developer adds a state to the list when cancel should be permitted. For a financial workflow, the conservative default (cannot cancel) is the correct failure mode.
Example 11: Dispute Transition and Resolution (Java)
The Disputed state is an off-ramp from several states and resolves back to either Approved or Cancelled. Java enums model the state set with inherent exhaustiveness.
stateDiagram-v2
Approved --> Disputed: dispute
Issued --> Disputed: dispute
Acknowledged --> Disputed: dispute
Disputed --> Approved: resolve_approve
Disputed --> Cancelled: resolve_cancel
note right of Disputed
Resolution paths:
1. resolve_approve → back to Approved
2. resolve_cancel → terminal
end note
classDef active fill:#029E73,stroke:#000,color:#fff
classDef disputed fill:#CC78BC,stroke:#000,color:#fff
classDef terminal fill:#CA9161,stroke:#000,color:#fff
class Approved,Issued,Acknowledged active
class Disputed disputed
class Cancelled terminal
// => Java enum: every state is a named constant — no magic strings, no typos possible
public enum POState {
DRAFT, AWAITING_APPROVAL, APPROVED, ISSUED,
ACKNOWLEDGED, DISPUTED, CLOSED, CANCELLED;
// => Enum constants: compile-time safe, exhaustively matchable in switch expressions
}
// => POEvent: typed event alphabet — only valid events exist at compile time
public enum POEvent {
SUBMIT, APPROVE, REJECT, ISSUE,
ACKNOWLEDGE, CLOSE, CANCEL, DISPUTE,
RESOLVE_APPROVE, RESOLVE_CANCEL;
// => RESOLVE_APPROVE: dispute resolved in PO's favour — reinstates to Approved
// => RESOLVE_CANCEL: dispute unrecoverable — PO terminates as Cancelled
}
// => PurchaseOrder: immutable record — Java 16+, auto-generates equals/hashCode/toString
public record PurchaseOrder(String id, double totalAmount, POState state) {
// => No setters: FSM transitions return NEW instances rather than mutating this one
// => Record components become final fields — guarantees state is never altered in place
}
// => Dispute-aware transition function: handles dispute entry and both resolution paths
// => Optional<PurchaseOrder>: empty signals "no valid transition" — avoids null or exception
public static Optional<PurchaseOrder> transition(PurchaseOrder po, POEvent event) {
return switch (po.state()) {
// => Outer switch on current state — Java 21 switch expression, exhaustive
case APPROVED -> switch (event) {
// => Inner switch on event given state is APPROVED
case ISSUE -> Optional.of(new PurchaseOrder(po.id(), po.totalAmount(), POState.ISSUED));
// => Approved + ISSUE → Issued: finance sends PO to supplier
case DISPUTE -> Optional.of(new PurchaseOrder(po.id(), po.totalAmount(), POState.DISPUTED));
// => Approved + DISPUTE → Disputed: discrepancy found post-approval
case CANCEL -> Optional.of(new PurchaseOrder(po.id(), po.totalAmount(), POState.CANCELLED));
// => Approved + CANCEL → Cancelled: revoked before issue
default -> Optional.empty();
// => All other events from APPROVED: invalid, return empty
};
case DISPUTED -> switch (event) {
// => Two resolution paths out of DISPUTED state
case RESOLVE_APPROVE -> Optional.of(new PurchaseOrder(po.id(), po.totalAmount(), POState.APPROVED));
// => RESOLVE_APPROVE: data error corrected — PO reinstated to Approved
case RESOLVE_CANCEL -> Optional.of(new PurchaseOrder(po.id(), po.totalAmount(), POState.CANCELLED));
// => RESOLVE_CANCEL: dispute unrecoverable — PO terminated
default -> Optional.empty();
// => Other events while in DISPUTED: invalid
};
default -> Optional.empty();
// => All other states (DRAFT, CLOSED, CANCELLED, etc.): unhandled events return empty
};
}
// => Example calls demonstrating the transition function
// transition(approvedPO, DISPUTE) → Optional[PO{state=DISPUTED}]
// transition(disputedPO, RESOLVE_APPROVE) → Optional[PO{state=APPROVED}]
// transition(disputedPO, RESOLVE_CANCEL) → Optional[PO{state=CANCELLED}]
// transition(closedPO, DISPUTE) → Optional.empty()Key Takeaway: Typed state and event enums give the FSM a compile-safe alphabet; Optional / nullable returns communicate "no valid transition" without throwing exceptions.
Why It Matters: Using Optional instead of null or exception for invalid transitions aligns with Java's modern idioms and forces callers to handle the empty case. In a REST controller, the controller maps Optional.empty() to a 400 Bad Request — a clean separation between domain logic and HTTP semantics.
Modelling the Full PO Lifecycle (Examples 12-17)
Example 12: The Full Transition Table
Putting the complete PurchaseOrder state machine — all states and transitions including the dispute cycle — into a single transition map.
stateDiagram-v2
[*] --> Draft
Draft --> AwaitingApproval: submit
Draft --> Cancelled: cancel
AwaitingApproval --> Approved: approve
AwaitingApproval --> Cancelled: reject
AwaitingApproval --> Cancelled: cancel
Approved --> Issued: issue
Approved --> Cancelled: cancel
Approved --> Disputed: dispute
Issued --> Acknowledged: acknowledge
Issued --> Cancelled: cancel
Issued --> Disputed: dispute
Acknowledged --> Closed: close
Acknowledged --> Cancelled: cancel
Acknowledged --> Disputed: dispute
Disputed --> Approved: resolve_approve
Disputed --> Cancelled: resolve_cancel
Closed --> [*]
Cancelled --> [*]
classDef draft fill:#0173B2,stroke:#000,color:#fff
classDef waiting fill:#DE8F05,stroke:#000,color:#000
classDef active fill:#029E73,stroke:#000,color:#fff
classDef terminal fill:#CA9161,stroke:#000,color:#fff
classDef dispute fill:#CC78BC,stroke:#000,color:#fff
class Draft draft
class AwaitingApproval waiting
class Approved,Issued,Acknowledged active
class Closed,Cancelled terminal
class Disputed dispute
// => Full PO transition table as a nested Map: Map<POState, Map<POEvent, POState>>
// => Map.of() / Map.entry(): creates unmodifiable maps — table is immutable at load time
import java.util.Map;
import java.util.Optional;
private static final Map<POState, Map<POEvent, POState>> PO_TRANSITIONS = Map.of(
// => Each outer entry: source state → (event → target state)
POState.DRAFT, Map.of(
POEvent.SUBMIT, POState.AWAITING_APPROVAL, // => Buyer submits for approval
POEvent.CANCEL, POState.CANCELLED // => Buyer abandons before submitting
),
POState.AWAITING_APPROVAL, Map.of(
POEvent.APPROVE, POState.APPROVED, // => Manager approves
POEvent.REJECT, POState.CANCELLED, // => Manager rejects — treated as cancel
POEvent.CANCEL, POState.CANCELLED // => Explicit cancellation
),
POState.APPROVED, Map.of(
POEvent.ISSUE, POState.ISSUED, // => Finance sends PO to supplier
POEvent.CANCEL, POState.CANCELLED, // => Revoked before issue
POEvent.DISPUTE, POState.DISPUTED // => Discrepancy found after approval
),
POState.ISSUED, Map.of(
POEvent.ACKNOWLEDGE, POState.ACKNOWLEDGED, // => Supplier confirms PO receipt
POEvent.CANCEL, POState.CANCELLED, // => Supplier cannot fulfil
POEvent.DISPUTE, POState.DISPUTED // => Discrepancy post-issue
),
POState.ACKNOWLEDGED, Map.of(
POEvent.CLOSE, POState.CLOSED, // => All received, paid, done
POEvent.CANCEL, POState.CANCELLED, // => Abandon after acknowledgement
POEvent.DISPUTE, POState.DISPUTED // => Discrepancy found post-acknowledgement
),
POState.DISPUTED, Map.of(
POEvent.RESOLVE_APPROVE, POState.APPROVED, // => Error corrected: reinstate PO
POEvent.RESOLVE_CANCEL, POState.CANCELLED // => Unrecoverable: terminate PO
)
// => CLOSED and CANCELLED: absent from outer map — terminal states with no transitions
);
// => applyEvent: table-driven FSM interpreter — O(1) lookup for both levels
public static Optional<PurchaseOrder> applyEvent(PurchaseOrder po, POEvent event) {
return Optional.ofNullable(PO_TRANSITIONS.get(po.state()))
// => getOrDefault outer: returns null if state has no entries (terminal state)
.map(stateMap -> stateMap.get(event))
// => map: if state has entries, look up the event; absent event → null
.map(nextState -> new PurchaseOrder(po.id(), po.totalAmount(), nextState));
// => map: if event found, build new PurchaseOrder with updated state
// => If either lookup missed, Optional remains empty — caller handles the gap
}
// => Walk the happy path: DRAFT → AWAITING_APPROVAL → APPROVED → ISSUED
PurchaseOrder po = new PurchaseOrder("po_full", 5000.0, POState.DRAFT);
for (POEvent evt : List.of(POEvent.SUBMIT, POEvent.APPROVE, POEvent.ISSUE)) {
Optional<PurchaseOrder> result = applyEvent(po, evt);
// => Each step: apply event, unwrap, continue; empty means invalid transition
po = result.orElse(po); // => On success: advance; on failure: keep current state
System.out.println(po.state());
}
// => Output (3 lines): AWAITING_APPROVAL / APPROVED / ISSUEDKey Takeaway: A single table and a single interpreter function handle the entire PO lifecycle — adding a new transition is one map entry, not a code change.
Why It Matters: A full procurement lifecycle has 10+ states and 15+ events. Encoding this in imperative if/else chains produces 150+ condition combinations to mentally verify. The table is the specification — if it matches the whiteboard diagram, the code is correct by construction.
Example 13: Event Log and Audit Trail
Every transition should append to an audit trail. This example extends the PO with a log of events that drove each state change.
// => LogEntry: immutable record capturing one state transition for audit purposes
// => record auto-generates equals/hashCode/toString — safe to store in unmodifiable lists
public record LogEntry(
POEvent event, // => Which event triggered the transition
POState fromState, // => State before the transition
POState toState, // => State after the transition
String timestamp // => ISO-8601 wall-clock time (inject Clock for testability)
) {}
// => AuditedPO: extends PurchaseOrder with an immutable ordered event log
// => List<LogEntry> is unmodifiable — only the transition function can grow it
public record AuditedPO(
String id,
double totalAmount,
POState state,
List<LogEntry> eventLog // => Immutable snapshot: List.copyOf enforced in constructor
) {
public AuditedPO {
eventLog = List.copyOf(eventLog);
// => List.copyOf: prevents external mutation after construction
}
}
// => auditedTransition: atomically updates state AND appends to the log
// => Both changes happen in one pure function — they cannot diverge
public static Optional<AuditedPO> auditedTransition(AuditedPO po, POEvent event) {
Map<POEvent, POState> stateMap = PO_TRANSITIONS.get(po.state());
// => Outer map lookup: null if state is terminal (no outgoing transitions)
if (stateMap == null) return Optional.empty();
POState nextState = stateMap.get(event);
// => Inner map lookup: null if event is not valid from this state
if (nextState == null) return Optional.empty();
// => Both levels must match: otherwise invalid transition, return empty
LogEntry entry = new LogEntry(
event, po.state(), nextState,
java.time.Instant.now().toString()
// => Instant.now(): wall-clock time in UTC; swap for Clock.instant() in tests
);
List<LogEntry> newLog = new ArrayList<>(po.eventLog());
newLog.add(entry);
// => Append entry to a mutable copy, then wrap in unmodifiable list below
return Optional.of(new AuditedPO(
po.id(), po.totalAmount(), nextState, List.copyOf(newLog)
// => New AuditedPO: state and log updated atomically — original po unchanged
));
}
// => Test: walk Draft → AwaitingApproval → Approved, verify log
AuditedPO apo = new AuditedPO("po_audit", 200.0, POState.DRAFT, List.of());
Optional<AuditedPO> r1 = auditedTransition(apo, POEvent.SUBMIT);
// => r1: present with state=AWAITING_APPROVAL, log has 1 entry
Optional<AuditedPO> r2 = r1.flatMap(p -> auditedTransition(p, POEvent.APPROVE));
// => flatMap: chains Optional — if r1 empty, r2 is also empty
r2.ifPresent(p -> {
System.out.println(p.state()); // => Output: APPROVED
System.out.println(p.eventLog().size()); // => Output: 2 (SUBMIT + APPROVE)
System.out.println(p.eventLog().get(0).event()); // => Output: SUBMIT
});Key Takeaway: Appending to an immutable event log in the same operation as the state transition keeps audit records structurally coupled to state changes — they cannot diverge.
Why It Matters: Procurement systems are subject to audit. If the event log is updated by a separate call after the transition, a crash between the two leaves the log inconsistent with the state. Updating both in a single pure function guarantees they are always in sync — no compensating logic required.
Example 14: FSM with Validation and Line Items
Combining the transition table, structural guard (at least one line), and a validated apply function into a single cohesive FSM module demonstrates how all the pieces compose.
// => POLine: immutable value object — one product line in the PO
public record POLine(
String skuCode, // => Product SKU, e.g. "ELC-0042"
int quantity, // => Must be > 0 (validated before transition)
double unitPrice // => USD price per unit
) {}
// => PurchaseOrderFull: immutable PO record with a list of line items
public record PurchaseOrderFull(
String id,
double totalAmount,
POState state,
List<POLine> lines // => Unmodifiable snapshot via List.copyOf in constructor
) {
public PurchaseOrderFull {
lines = List.copyOf(lines);
// => Compact constructor: enforces immutability on the lines collection
}
}
// => Validation result: wraps a validated PO or a list of error messages
// => Using List<String> for errors allows multiple violations to be reported at once
public sealed interface ValidationResult permits ValidationResult.Valid, ValidationResult.Invalid {
record Valid(PurchaseOrderFull po) implements ValidationResult {}
// => Valid: contains the successfully validated PO
record Invalid(List<String> errors) implements ValidationResult {}
// => Invalid: contains one or more error messages; List.copyOf used internally
}
// => validate: checks all structural invariants before allowing transition to ISSUED
public static ValidationResult validate(PurchaseOrderFull po) {
List<String> errors = new ArrayList<>();
if (po.lines().isEmpty()) {
errors.add("Must have at least one line item");
// => Structural invariant: a PO with no items has no procurement meaning
}
boolean anyNonPositive = po.lines().stream().anyMatch(l -> l.quantity() <= 0);
// => anyMatch: short-circuits on first non-positive quantity — O(1) best case
if (anyNonPositive) {
errors.add("All line quantities must be > 0");
// => Per-line invariant: zero or negative quantity is invalid
}
if (po.totalAmount() <= 0) {
errors.add("Total amount must be > 0");
// => Financial invariant: a PO with non-positive total cannot be approved
}
return errors.isEmpty()
? new ValidationResult.Valid(po)
// => No errors: PO is structurally sound, safe to transition
: new ValidationResult.Invalid(List.copyOf(errors));
// => One or more errors: caller must fix before retrying the transition
}
// => applyValidatedEvent: validates PO before attempting to move to ISSUED
// => Other transitions skip validation — only the ISSUED gate requires full checks
public static Optional<PurchaseOrderFull> applyValidatedEvent(
PurchaseOrderFull po, POEvent event) {
if (event == POEvent.ISSUE) {
// => ISSUE is the only transition that requires structural validation
ValidationResult vr = validate(po);
if (vr instanceof ValidationResult.Invalid inv) {
System.out.println("Validation failed: " + inv.errors());
// => Print errors; production code would return them to the caller
return Optional.empty();
}
}
Map<POEvent, POState> stateMap = PO_TRANSITIONS.get(po.state());
// => Look up outgoing transitions for the current state
if (stateMap == null) return Optional.empty();
// => Terminal state: no outgoing transitions
POState nextState = stateMap.get(event);
if (nextState == null) return Optional.empty();
// => Event not valid from this state: forbidden transition
return Optional.of(new PurchaseOrderFull(
po.id(), po.totalAmount(), nextState, po.lines()
// => New record with updated state; line list preserved unchanged
));
}
// => Test: attempt to issue a PO with no lines — validation blocks the transition
PurchaseOrderFull po = new PurchaseOrderFull(
"po_val_01", 3000.0, POState.APPROVED, List.of()
// => Approved state, zero lines — structural invariant violation
);
Optional<PurchaseOrderFull> result = applyValidatedEvent(po, POEvent.ISSUE);
System.out.println(result.isPresent()); // => Output: false
// => Validation failed: [Must have at least one line item]
// => Test: PO with a valid line transitions successfully
PurchaseOrderFull poWithLine = new PurchaseOrderFull(
"po_val_02", 800.0, POState.APPROVED,
List.of(new POLine("ELC-0042", 10, 80.0))
// => One valid line: structural invariant satisfied
);
Optional<PurchaseOrderFull> result2 = applyValidatedEvent(poWithLine, POEvent.ISSUE);
result2.ifPresent(p -> System.out.println(p.state())); // => Output: ISSUEDKey Takeaway: Validation and transition logic compose cleanly when each concern lives in its own function — the guard checks invariants, the table checks reachability, and the interpreter orchestrates both.
Why It Matters: Learning the pattern without a framework first means you understand what libraries like transitions or XState are doing underneath. When you reach for a library, you can evaluate it against the first-principles model rather than treating it as a black box.
Example 15: Enum-Based Transition Table
An enum-keyed transition table makes the FSM definition exhaustive and type-safe: the compiler rejects any state or event value not declared in the enum, and a static or immutable map becomes the single authoritative source of valid moves. Java uses an EnumMap backed by enum ordinals; Kotlin combines enum keys with copy() for immutable updates; C# uses an enum-keyed Dictionary paired with with expressions; TypeScript uses a string literal union and a Record-mapped object — all four eliminate stringly-typed state keys while keeping lookup O(1).
// => Transition table as an immutable Map — built once at class load time
import java.util.*;
public class POStateMachine {
// => Enums: compile-time type safety — compiler rejects any unlisted value
public enum State { DRAFT, AWAITING_APPROVAL, APPROVED, ISSUED,
ACKNOWLEDGED, DISPUTED, CLOSED, CANCELLED }
// => Event enum mirrors every trigger in the FSM diagram
public enum Event { SUBMIT, APPROVE, REJECT, ISSUE, ACKNOWLEDGE,
CLOSE, CANCEL, DISPUTE, RESOLVE_APPROVE, RESOLVE_CANCEL }
// => Static transition table: Map<currentState, Map<event, nextState>>
// => 'final' ensures reference is never replaced; Map.of() makes each inner map unmodifiable
private static final Map<State, Map<Event, State>> TABLE;
static {
// => Static initialiser block: runs once when class is loaded by JVM
TABLE = new EnumMap<>(State.class);
// => EnumMap: backed by an array indexed by enum ordinal — O(1) lookup, no boxing
TABLE.put(State.DRAFT,
Map.of(Event.SUBMIT, State.AWAITING_APPROVAL,
Event.CANCEL, State.CANCELLED));
// => DRAFT can only be submitted or cancelled
TABLE.put(State.AWAITING_APPROVAL,
Map.of(Event.APPROVE, State.APPROVED,
Event.REJECT, State.CANCELLED));
TABLE.put(State.APPROVED,
Map.of(Event.ISSUE, State.ISSUED,
Event.CANCEL, State.CANCELLED,
Event.DISPUTE, State.DISPUTED));
TABLE.put(State.ISSUED,
Map.of(Event.ACKNOWLEDGE, State.ACKNOWLEDGED,
Event.CANCEL, State.CANCELLED));
TABLE.put(State.ACKNOWLEDGED,
Map.of(Event.CLOSE, State.CLOSED,
Event.CANCEL, State.CANCELLED));
TABLE.put(State.DISPUTED,
Map.of(Event.RESOLVE_APPROVE, State.APPROVED,
Event.RESOLVE_CANCEL, State.CANCELLED));
// => CLOSED and CANCELLED: no entries — they are terminal states
}
// => record: immutable value type — auto-generates equals, hashCode, toString, accessors
public record PurchaseOrder(String id, double totalAmount, State state) {}
// => Returns Optional.of(newPO) for valid transitions, Optional.empty() for invalid ones
public static Optional<PurchaseOrder> apply(PurchaseOrder po, Event event) {
return Optional.ofNullable(
TABLE.getOrDefault(po.state(), Map.of()).get(event))
// => getOrDefault: terminal states map to empty map, avoiding NullPointerException
.map(next -> new PurchaseOrder(po.id(), po.totalAmount(), next));
// => map: null next-state becomes empty Optional; non-null builds new record
}
public static void main(String[] args) {
var po = new PurchaseOrder("po_j01", 1000.0, State.DRAFT);
// => po.state() == DRAFT (accessor auto-generated by record)
System.out.println(apply(po, Event.SUBMIT));
// => Output: Optional[PurchaseOrder[id=po_j01, totalAmount=1000.0, state=AWAITING_APPROVAL]]
var closed = new PurchaseOrder("po_j01", 1000.0, State.CLOSED);
System.out.println(apply(closed, Event.CANCEL));
// => Output: Optional.empty — terminal state, no outgoing transitions
}
}Key Takeaway: Using type-safe, enum-keyed transition tables enforces immutability at the type level and makes the table the single authoritative source of valid moves. Java uses EnumMap; Kotlin uses enum keys with copy(); C# uses enum-keyed Dictionary with with expressions; TypeScript uses a Record<StateEnum, ...> mapped type — all eliminate stringly-typed state keys.
Why It Matters: HashMap<String, ...> or Dictionary<string, string> with string keys reintroduces the stringly-typed state problem. Enum-keyed maps use ordinal indices or hash-by-value — no accidental typos, and the compiler checks that every key is a valid state. For a PO machine called thousands of times per second in a procurement system, type safety and O(1) lookup are both wins.
Entry/Exit Actions and Notifications (Examples 16-20)
Example 16: Entry Action on AwaitingApproval
When a PO enters AwaitingApproval, the system should route the approval request to the responsible manager. This side effect is an entry action — it runs when entering a state, not when leaving one.
stateDiagram-v2
[*] --> Draft
Draft --> AwaitingApproval: submit
AwaitingApproval --> Approved: approve
note right of AwaitingApproval
Entry action fires on entering:
Route approval request to manager
end note
classDef draft fill:#0173B2,stroke:#000,color:#fff
classDef waiting fill:#DE8F05,stroke:#000,color:#000
classDef approved fill:#029E73,stroke:#000,color:#fff
class Draft draft
class AwaitingApproval waiting
class Approved approved
import java.util.*;
import java.util.function.Function;
public class POEntryActions {
public enum State { DRAFT, AWAITING_APPROVAL, APPROVED, ISSUED, ACKNOWLEDGED,
DISPUTED, CLOSED, CANCELLED }
public enum Event { SUBMIT, APPROVE, REJECT, ISSUE, ACKNOWLEDGE,
CLOSE, CANCEL, DISPUTE, RESOLVE_APPROVE, RESOLVE_CANCEL }
// => record: immutable value type — id, totalAmount, state are auto-accessors
public record PurchaseOrder(String id, double totalAmount, State state) {}
// => Entry action type alias: Function<PurchaseOrder, String>
// => Returns a description string so side effects are testable without real infrastructure
private static final Map<State, Function<PurchaseOrder, String>> ENTRY_ACTIONS =
new EnumMap<>(State.class);
static {
// => Register entry actions in static initialiser — runs once at class load
ENTRY_ACTIONS.put(State.AWAITING_APPROVAL,
po -> String.format("Route approval request for PO %s ($%.0f) to manager",
po.id(), po.totalAmount()));
// => In production: invoke ApprovalRouterPort here; returning String for testability
ENTRY_ACTIONS.put(State.ISSUED,
po -> String.format("Send PO %s to supplier via EDI/email", po.id()));
// => In production: invoke SupplierNotifierPort
ENTRY_ACTIONS.put(State.CANCELLED,
po -> String.format("Notify all parties: PO %s cancelled", po.id()));
// => In production: invoke SupplierNotifierPort + accounting system
}
// => Transition table — same EnumMap pattern as Example 15
private static final Map<State, Map<Event, State>> TABLE = new EnumMap<>(State.class);
static {
TABLE.put(State.DRAFT,
Map.of(Event.SUBMIT, State.AWAITING_APPROVAL, Event.CANCEL, State.CANCELLED));
// => Only DRAFT→AWAITING_APPROVAL and DRAFT→CANCELLED defined here for brevity
}
// => Result carrier: holds new PO plus optional side-effect description
public record TransitionResult(Optional<PurchaseOrder> po, Optional<String> sideEffect) {}
public static TransitionResult applyWithEntry(PurchaseOrder po, Event event) {
// => Step 1: pure FSM transition — no side effects
var nextState = Optional.ofNullable(
TABLE.getOrDefault(po.state(), Map.of()).get(event));
if (nextState.isEmpty()) {
// => Invalid transition: return empty result, no side effect fires
return new TransitionResult(Optional.empty(), Optional.empty());
}
// => Step 2: build new immutable PO record
var newPO = new PurchaseOrder(po.id(), po.totalAmount(), nextState.get());
// => Step 3: look up entry action for the new state
var action = Optional.ofNullable(ENTRY_ACTIONS.get(newPO.state()));
// => Run action if registered; Optional.empty() if this state has no entry action
var effect = action.map(fn -> fn.apply(newPO));
return new TransitionResult(Optional.of(newPO), effect);
}
public static void main(String[] args) {
var po = new PurchaseOrder("po_entry", 8500.0, State.DRAFT);
var result = applyWithEntry(po, Event.SUBMIT);
// => Submit fires: Draft → AwaitingApproval
result.po().ifPresent(p -> System.out.println(p.state()));
// => Output: AWAITING_APPROVAL
result.sideEffect().ifPresent(System.out::println);
// => Output: Route approval request for PO po_entry ($8500) to manager
}
}Key Takeaway: Entry actions separate the pure FSM transition from its side effects — the machine always transitions correctly, even if the side effect fails or is skipped in tests.
Why It Matters: A common mistake is mixing approval routing logic into the transition function itself. If the router throws, the PO state is neither updated nor rolled back cleanly. By making the transition pure and the side effect separate, you can: (a) test the state change without a real router, (b) retry the side effect independently, and (c) swap the router implementation without touching the FSM.
Example 17: Exit Action on Issued
When leaving Issued (via acknowledge), the system should log that the supplier acknowledged the PO. Exit actions fire when leaving a state.
import java.util.*;
import java.util.function.BiFunction;
public class POExitActions {
public enum State { DRAFT, AWAITING_APPROVAL, APPROVED, ISSUED,
ACKNOWLEDGED, DISPUTED, CLOSED, CANCELLED }
public enum Event { SUBMIT, APPROVE, REJECT, ISSUE, ACKNOWLEDGE,
CLOSE, CANCEL, DISPUTE, RESOLVE_APPROVE, RESOLVE_CANCEL }
public record PurchaseOrder(String id, double totalAmount, State state) {}
// => Exit action: BiFunction<PurchaseOrder, Event, String>
// => Receives the pre-transition PO and the triggering event
// => Returns a description string — testable without real infrastructure
private static final Map<State, BiFunction<PurchaseOrder, Event, String>> EXIT_ACTIONS =
new EnumMap<>(State.class);
static {
EXIT_ACTIONS.put(State.ISSUED, (po, event) ->
event == Event.ACKNOWLEDGE
// => Supplier acknowledged: GRN window opens
? String.format("PO %s acknowledged by supplier — GRN window now open", po.id())
// => Any other event leaving Issued (e.g., CANCEL)
: String.format("PO %s leaving ISSUED state via %s", po.id(), event));
}
// => Entry action map (abbreviated — same pattern as Example 16)
private static final Map<State, java.util.function.Function<PurchaseOrder, String>> ENTRY_ACTIONS =
new EnumMap<>(State.class);
static {
ENTRY_ACTIONS.put(State.ACKNOWLEDGED,
po -> String.format("Open GRN window for PO %s", po.id()));
// => In production: trigger goods-receipt-note workflow
}
// => Transition table (abbreviated to ISSUED for clarity)
private static final Map<State, Map<Event, State>> TABLE = new EnumMap<>(State.class);
static {
TABLE.put(State.ISSUED,
Map.of(Event.ACKNOWLEDGE, State.ACKNOWLEDGED,
Event.CANCEL, State.CANCELLED));
}
// => Record carrying all three results: new PO + both side-effect descriptions
public record ActionResult(
Optional<PurchaseOrder> po,
Optional<String> exitEffect,
Optional<String> entryEffect) {}
public static ActionResult applyWithActions(PurchaseOrder po, Event event) {
// => Step 1: run exit action BEFORE transition — we are leaving current state
var exitAction = Optional.ofNullable(EXIT_ACTIONS.get(po.state()));
var exitEffect = exitAction.map(fn -> fn.apply(po, event));
// => exit receives pre-transition PO: correct, because we have not moved yet
// => Step 2: perform pure FSM transition
var nextState = Optional.ofNullable(
TABLE.getOrDefault(po.state(), Map.of()).get(event));
if (nextState.isEmpty()) {
// => Invalid event: discard exit effect — transition did not happen
return new ActionResult(Optional.empty(), Optional.empty(), Optional.empty());
}
// => Step 3: build new immutable record
var newPO = new PurchaseOrder(po.id(), po.totalAmount(), nextState.get());
// => Step 4: run entry action for new state
var entryAction = Optional.ofNullable(ENTRY_ACTIONS.get(newPO.state()));
var entryEffect = entryAction.map(fn -> fn.apply(newPO));
// => entry receives post-transition PO: correct, it needs the new state
return new ActionResult(Optional.of(newPO), exitEffect, entryEffect);
}
public static void main(String[] args) {
var issuedPO = new PurchaseOrder("po_ack", 1200.0, State.ISSUED);
var r = applyWithActions(issuedPO, Event.ACKNOWLEDGE);
r.po().ifPresent(p -> System.out.println(p.state()));
// => Output: ACKNOWLEDGED
r.exitEffect().ifPresent(System.out::println);
// => Output: PO po_ack acknowledged by supplier — GRN window now open
System.out.println(r.entryEffect().isPresent() ? r.entryEffect().get() : "(none)");
// => Output: Open GRN window for PO po_ack
}
}Key Takeaway: Entry and exit actions are ordered: exit fires before transition, entry fires after — the order is part of the FSM contract, not an implementation detail.
Why It Matters: Ordering matters because exit actions might need the pre-transition state (e.g., logging "left Issued") while entry actions need the post-transition state (e.g., "now in Acknowledged, open GRN window"). Encoding this order in the runner function makes it consistent across all transitions.
Example 18: Testing FSM Transitions
FSMs built from pure functions and immutable records are trivially testable — no mocks, no database, no HTTP client.
import java.util.*;
// => Self-contained test harness: no JUnit dependency required
// => In production: replace with @Test methods and JUnit 5 assertions
public class POFsmTest {
public enum State { DRAFT, AWAITING_APPROVAL, APPROVED, ISSUED,
ACKNOWLEDGED, DISPUTED, CLOSED, CANCELLED }
public enum Event { SUBMIT, APPROVE, REJECT, ISSUE, ACKNOWLEDGE,
CLOSE, CANCEL, DISPUTE, RESOLVE_APPROVE, RESOLVE_CANCEL }
public record PurchaseOrder(String id, double totalAmount, State state) {}
// => Transition table — same EnumMap as Example 15
private static final Map<State, Map<Event, State>> TABLE = new EnumMap<>(State.class);
static {
TABLE.put(State.DRAFT,
Map.of(Event.SUBMIT, State.AWAITING_APPROVAL, Event.CANCEL, State.CANCELLED));
TABLE.put(State.AWAITING_APPROVAL,
Map.of(Event.APPROVE, State.APPROVED, Event.REJECT, State.CANCELLED));
TABLE.put(State.APPROVED,
Map.of(Event.ISSUE, State.ISSUED, Event.CANCEL, State.CANCELLED,
Event.DISPUTE, State.DISPUTED));
TABLE.put(State.ISSUED,
Map.of(Event.ACKNOWLEDGE, State.ACKNOWLEDGED, Event.CANCEL, State.CANCELLED));
TABLE.put(State.ACKNOWLEDGED,
Map.of(Event.CLOSE, State.CLOSED, Event.CANCEL, State.CANCELLED));
TABLE.put(State.DISPUTED,
Map.of(Event.RESOLVE_APPROVE, State.APPROVED, Event.RESOLVE_CANCEL, State.CANCELLED));
}
// => Pure apply: Optional.of(newPO) or Optional.empty() for invalid event
static Optional<PurchaseOrder> apply(PurchaseOrder po, Event event) {
return Optional.ofNullable(TABLE.getOrDefault(po.state(), Map.of()).get(event))
.map(next -> new PurchaseOrder(po.id(), po.totalAmount(), next));
}
// => Minimal assertion helper: prints PASS/FAIL; throw halts test suite
static void assertEquals(Object expected, Object actual, String label) {
if (!expected.equals(actual))
throw new AssertionError(label + ": expected " + expected + " but got " + actual);
System.out.println(" PASS: " + label);
// => Visual confirmation: each line is one passing assertion
}
// => Test 1: happy path Draft → AwaitingApproval
static void testSubmitTransition() {
var po = new PurchaseOrder("po_t01", 1000.0, State.DRAFT);
// => Arrange: PO starts in DRAFT
var result = apply(po, Event.SUBMIT);
// => Act: apply SUBMIT event
assertEquals(true, result.isPresent(), "submit is valid");
// => Assert: transition succeeded (Optional is not empty)
assertEquals(State.AWAITING_APPROVAL, result.get().state(), "new state");
// => Assert: PO advanced to AWAITING_APPROVAL
}
// => Test 2: invalid transition — APPROVE cannot be applied from DRAFT
static void testInvalidApproveFromDraft() {
var po = new PurchaseOrder("po_t02", 500.0, State.DRAFT);
// => Arrange: DRAFT state, will attempt APPROVE (not a valid event here)
var result = apply(po, Event.APPROVE);
// => Act: APPROVE from DRAFT — should be rejected
assertEquals(false, result.isPresent(), "approve-from-draft returns empty");
// => Assert: Optional.empty() signals invalid transition
}
// => Test 3: cancel from any cancellable state
static void testCancelFromApproved() {
var approved = new PurchaseOrder("po_t03", 200.0, State.APPROVED);
// => Arrange: PO is already APPROVED
var result = apply(approved, Event.CANCEL);
// => Act: cancel — allowed from APPROVED
assertEquals(true, result.isPresent(), "cancel from APPROVED is valid");
assertEquals(State.CANCELLED, result.get().state(), "new state is CANCELLED");
// => Assert: PO moved to terminal CANCELLED state
}
public static void main(String[] args) {
testSubmitTransition();
testInvalidApproveFromDraft();
testCancelFromApproved();
// => Output:
// => PASS: submit is valid
// => PASS: new state
// => PASS: approve-from-draft returns empty
// => PASS: cancel from APPROVED is valid
// => PASS: new state is CANCELLED
}
}Key Takeaway: Pure FSM functions require zero test infrastructure — no database setup, no HTTP mocking, no class instantiation beyond the data record itself.
Why It Matters: Test speed is test quality. When every FSM test is a function call that runs in microseconds, developers run tests on every save. When tests require a running database or a started HTTP server, they run only in CI. The functional FSM pattern makes the former trivially achievable.
Example 19: Deriving the Total from Line Items
The PO's totalAmount should be computed from its line items, not stored as a free-standing number — a computed property enforces the invariant that total = sum of lines.
import java.util.List;
import java.util.Optional;
public class POTotalValidation {
// => POLine: immutable value type representing a single line on the PO
public record POLine(String skuCode, int quantity, double unitPrice) {}
// => PurchaseOrder with embedded lines: single source of truth for total
public record PurchaseOrderWithLines(
String id,
double totalAmount, // => Stored total — must match computed sum of lines
List<POLine> lines // => Canonical source; totalAmount derived from this
) {}
// => Pure function: computes total from lines only, no stored value used
// => stream().mapToDouble(): clean functional accumulation — O(n) single pass
public static double computeTotal(List<POLine> lines) {
return lines.stream()
.mapToDouble(line -> line.quantity() * line.unitPrice())
// => Each line contributes quantity × unitPrice to the sum
.sum();
// => DoubleStream.sum(): accurate, handles IEEE 754 edge cases better than reduce
}
// => Guard: validates stored total against computed total
// => Returns error string on mismatch, Optional.empty() if consistent
public static Optional<String> validateTotal(PurchaseOrderWithLines po) {
double computed = computeTotal(po.lines());
// => Recompute from canonical source — never trust stored total without verification
double delta = Math.abs(computed - po.totalAmount());
// => Floating-point safe: allow rounding error smaller than half a cent
if (delta > 0.005) {
return Optional.of(
String.format("Total mismatch: stored $%.2f vs computed $%.2f",
po.totalAmount(), computed));
// => Inconsistency detected: lines do not add up to stored total
}
return Optional.empty();
// => Consistent: guard passes, FSM transition may proceed
}
public static void main(String[] args) {
var lines = List.of(
new POLine("ELC-0042", 5, 100.0), // => 5 × $100 = $500
new POLine("ELC-0099", 10, 25.0) // => 10 × $25 = $250
);
var total = computeTotal(lines);
// => total = 750.0
System.out.println(total);
// => Output: 750.0
var consistentPO = new PurchaseOrderWithLines("po_tot", 750.0, lines);
validateTotal(consistentPO).ifPresentOrElse(
System.out::println,
() -> System.out.println("(consistent)"));
// => Output: (consistent) — stored total matches computed total
var inconsistentPO = new PurchaseOrderWithLines("po_bad", 999.0, lines);
validateTotal(inconsistentPO).ifPresent(System.out::println);
// => Output: Total mismatch: stored $999.00 vs computed $750.00
}
}Key Takeaway: Derived values like total should be recomputable from the canonical source (line items) and validated as part of the guard chain — stored totals that can drift are a data-integrity risk.
Why It Matters: In financial systems, the total on a PO is a legally binding figure. If it can drift from the line-item sum — due to a rounding bug, a partial update, or a concurrent mutation — the system has a silent integrity failure. Validating the total as a guard before transitions catches the inconsistency at the FSM boundary, not in an accounting audit three months later.
Example 20: Constructing the Initial PO with Validation
A constructor function that validates all invariants before returning the PurchaseOrder record ensures the FSM always starts in a valid state.
import java.util.List;
import java.util.Optional;
public class POConstructor {
public enum POState { DRAFT, AWAITING_APPROVAL, APPROVED, ISSUED,
ACKNOWLEDGED, DISPUTED, CLOSED, CANCELLED }
// => POLine: immutable value — quantity × unitPrice forms the line's price
public record POLine(String skuCode, int quantity, double unitPrice) {}
// => PurchaseOrderWithLines: totalAmount always derived from lines
public record PurchaseOrderWithLines(
String id, double totalAmount, POState state, List<POLine> lines) {}
// => Result carrier: success wraps PO, failure wraps error message
// => sealed interface + records: exhaustive pattern matching in switch (Java 21+)
public sealed interface BuildResult permits BuildResult.Ok, BuildResult.Err {
record Ok(PurchaseOrderWithLines po) implements BuildResult {}
record Err(String message) implements BuildResult {}
}
// => Pure total computation from Example 19 — single source of truth
private static double computeTotal(List<POLine> lines) {
return lines.stream()
.mapToDouble(l -> l.quantity() * l.unitPrice())
.sum();
}
// => Smart constructor: validates all invariants, returns BuildResult not a raw PO
// => Guard order: cheap string checks first, costlier computations last
public static BuildResult buildPO(String id, List<POLine> lines) {
// => Invariant 1: id must start with "po_" and have meaningful suffix
if (!id.startsWith("po_") || id.length() < 7) {
return new BuildResult.Err(
"Invalid PO id format: " + id + " (expected po_<uuid>)");
// => Fail fast: reject malformed id before any further processing
}
// => Invariant 2: at least one line item required
if (lines.isEmpty()) {
return new BuildResult.Err("PO must have at least one line item");
// => Empty PO has no commercial value; cannot be submitted
}
// => Invariant 3: every line must have strictly positive quantity
var badLine = lines.stream()
.filter(l -> l.quantity() <= 0)
.findFirst();
if (badLine.isPresent()) {
return new BuildResult.Err(
String.format("Line %s has invalid quantity %d",
badLine.get().skuCode(), badLine.get().quantity()));
// => Per-line invariant: zero or negative quantities are nonsensical for a PO
}
// => All guards passed: derive total and construct PO in DRAFT state
double total = computeTotal(lines);
// => Total computed here, not accepted from caller — prevents stale/incorrect input
return new BuildResult.Ok(
new PurchaseOrderWithLines(id, total, POState.DRAFT, lines));
// => DRAFT is the only valid initial state for a newly constructed PO
}
public static void main(String[] args) {
// => Happy path: valid id, one valid line
var r1 = buildPO("po_001xx", List.of(new POLine("ELC-0042", 10, 50.0)));
if (r1 instanceof BuildResult.Ok ok)
System.out.println(ok.po().state() + ", total $" + ok.po().totalAmount());
// => Output: DRAFT, total $500.0
// => Failure: empty lines list
var r2 = buildPO("po_001xx", List.of());
if (r2 instanceof BuildResult.Err err) System.out.println(err.message());
// => Output: PO must have at least one line item
// => Failure: invalid id format
var r3 = buildPO("bad_id", List.of(new POLine("ELC-0042", 5, 10.0)));
if (r3 instanceof BuildResult.Err err) System.out.println(err.message());
// => Output: Invalid PO id format: bad_id (expected po_<uuid>)
}
}Key Takeaway: Smart constructors that validate all invariants and return Result ensure the FSM only ever receives valid input — garbage-in-garbage-out is prevented at the boundary.
Why It Matters: An FSM that starts in an invalid state will produce unpredictable transitions. Validating at construction time is cheaper than catching invariant violations mid-lifecycle, and it localises all validation logic in one place rather than scattering it across every transition.
State-Machine Patterns (Examples 21-25)
Example 21: State as a Discriminated Union (Advanced Typing)
Instead of a flat enum, each state can carry its own data — a discriminated union that makes invalid state-data combinations impossible.
// => Java 17+: sealed interface + records form a discriminated union
// => Each permitted type carries only the data valid for that state
// => Sealed interface: only the listed record types may implement POStateVariant
public sealed interface POStateVariant
permits POStateVariant.Draft, POStateVariant.AwaitingApproval,
POStateVariant.Approved, POStateVariant.Issued,
POStateVariant.Cancelled, POStateVariant.Disputed {
// => Draft: carries only the creation timestamp
record Draft(String createdAt) implements POStateVariant {}
// => AwaitingApproval: carries submitter identity and submission time
record AwaitingApproval(String submittedBy, String submittedAt)
implements POStateVariant {}
// => Approved: carries approver identity and approval time — needed for audit trail
record Approved(String approvedBy, String approvedAt)
implements POStateVariant {}
// => Issued: carries the time of issue and supplier's own reference number
record Issued(String issuedAt, String supplierRef)
implements POStateVariant {}
// => Cancelled: carries the cancellation reason — mandatory for compliance audit
record Cancelled(String reason) implements POStateVariant {}
// => Disputed: carries why the dispute was raised — needed for resolution workflow
record Disputed(String disputeReason) implements POStateVariant {}
}
public class PODiscriminatedUnion {
// => Pattern matching switch (Java 21+): compiler requires all sealed variants
// => Exhaustiveness checked at compile time — missing arm is a compile error
public static String describeState(POStateVariant s) {
return switch (s) {
case POStateVariant.Draft d ->
"Draft since " + d.createdAt();
// => Only Draft has createdAt — field inaccessible in other arms
case POStateVariant.AwaitingApproval wa ->
"Waiting for approval (submitted by " + wa.submittedBy() + ")";
// => Only AwaitingApproval has submittedBy — type narrowed by compiler
case POStateVariant.Approved a ->
"Approved by " + a.approvedBy();
case POStateVariant.Issued i ->
"Issued — supplier ref: " + i.supplierRef();
case POStateVariant.Cancelled c ->
"Cancelled: " + c.reason();
case POStateVariant.Disputed d ->
"Disputed: " + d.disputeReason();
// => No default needed: sealed type guarantees exhaustion
};
}
public static void main(String[] args) {
var approved = new POStateVariant.Approved("mgr_001", "2026-01-15");
// => Only Approved has approvedBy — cannot read this field via base type
System.out.println(describeState(approved));
// => Output: Approved by mgr_001
var disputed = new POStateVariant.Disputed("Quantity received does not match PO");
System.out.println(describeState(disputed));
// => Output: Disputed: Quantity received does not match PO
var draft = new POStateVariant.Draft("2026-01-10");
System.out.println(describeState(draft));
// => Output: Draft since 2026-01-10
}
}Key Takeaway: Discriminated union states make it impossible to access state-specific data in the wrong state — the compiler enforces state-data coherence.
Why It Matters: With flat enums, you might read po.approvedBy without first confirming the state is Approved, resulting in null or garbage at runtime. Discriminated unions make ApprovedBy structurally unavailable outside the Approved variant — the type system enforces what documentation could only request.
Example 22: Logging State Transitions for Observability
Production FSMs need structured logging of every transition — not just console output, but structured records that feed into tracing systems.
// => Java 17+: record carries all fields for a single structured log entry
// => Records are immutable by default — safe to share across threads
public record TransitionLog(
String poId, // => Which PO transitioned
String event, // => What event triggered the transition
String fromState, // => State name before the transition
String toState, // => State name after the transition
String timestamp, // => ISO-8601 wall clock — swap for deterministic clock in tests
String actorId // => Who triggered it; null for system-initiated events
) {}
public class LoggedTransitionDemo {
// => In-memory accumulator (in production: replace with SLF4J / OpenTelemetry span)
private static final List<TransitionLog> transitionLogs = new ArrayList<>();
// => Wrap the pure transition with a logging side-effect
// => Logging fires only when the transition succeeds — never on rejected events
public static Optional<PurchaseOrder> loggedTransition(
PurchaseOrder po, String event, String actorId) {
Optional<PurchaseOrder> result = applyEvent(po, event);
// => applyEvent returns empty Optional when the transition is invalid
result.ifPresent(next -> {
// => Only append on success: rejected events are caller errors, not business facts
transitionLogs.add(new TransitionLog(
po.id(),
event,
po.state().name(), // => fromState: name of the enum constant before
next.state().name(), // => toState: name of the enum constant after
java.time.Instant.now().toString(),
actorId
));
});
return result;
}
public static void main(String[] args) {
PurchaseOrder po = createPO("po_log01", 3000);
// => po.state() is POState.DRAFT
loggedTransition(po, "submit", "user_buyer_001");
// => Transition succeeds: log entry appended
System.out.println(transitionLogs.size());
// => Output: 1
System.out.println(transitionLogs.get(0).fromState());
// => Output: DRAFT
System.out.println(transitionLogs.get(0).toState());
// => Output: AWAITING_APPROVAL
}
}Key Takeaway: Logging only successful transitions keeps the audit trail clean — rejected events indicate caller bugs, not business events worth archiving.
Why It Matters: Filtering out invalid-transition attempts from the persistent log matters for audit clarity. If every rejected event were logged as a business event, auditors would see noise alongside real state changes. Rejections belong in application-level error logs (with a different sink), not in the business transaction audit trail.
Example 23: Replaying Events to Reconstruct State
If all events are stored, the current state is derivable by replaying them from the initial state — the foundation of event sourcing.
stateDiagram-v2
[*] --> Draft: createPO (initial)
Draft --> AwaitingApproval: replay: submit
AwaitingApproval --> Approved: replay: approve
Approved --> Issued: replay: issue
Issued --> Acknowledged: replay: acknowledge
note left of Draft
Replay restores state
by re-applying each
stored event in order
end note
classDef draft fill:#0173B2,stroke:#000,color:#fff
classDef active fill:#029E73,stroke:#000,color:#fff
classDef waiting fill:#DE8F05,stroke:#000,color:#000
class Draft draft
class AwaitingApproval waiting
class Approved,Issued,Acknowledged active
// => Java: replay folds a list of events over the initial state using a plain for-loop
// => Returns Optional<PurchaseOrder> — empty if any event in the sequence is invalid
public class ReplayDemo {
// => replay: applies events left-to-right starting from the freshly created Draft state
// => If any event is forbidden, replay stops and returns empty — caller sees failure
public static Optional<PurchaseOrder> replay(
String id, int totalAmount, List<String> events) {
PurchaseOrder po = createPO(id, totalAmount);
// => Start from initial state (DRAFT) — no events applied yet
for (String event : events) {
Optional<PurchaseOrder> result = applyEvent(po, event);
// => applyEvent returns empty when transition is forbidden
if (result.isEmpty()) {
// => Sequence is inconsistent: stored events cannot be replayed
System.err.println(
"Replay failed at event '" + event +
"' in state '" + po.state() + "'");
return Optional.empty();
}
po = result.get();
// => Advance to next state and continue replaying
}
return Optional.of(po);
// => Final projected state after all events applied
}
public static void main(String[] args) {
// => Happy-path: complete lifecycle in order
List<String> history = List.of("submit", "approve", "issue", "acknowledge");
replay("po_replay01", 2000, history)
.ifPresent(po -> System.out.println(po.state()));
// => Output: ACKNOWLEDGED
// => Sad-path: approve before submit — invalid sequence
List<String> badHistory = List.of("approve");
Optional<PurchaseOrder> bad = replay("po_replay02", 500, badHistory);
System.out.println(bad.isEmpty());
// => Output: true (replay failed; error printed to stderr)
}
}Key Takeaway: A list of events is a complete description of PO history — the current state is a derived projection, not the source of truth.
Why It Matters: Event sourcing stores the event sequence rather than the current state. The FSM replay function is then the read-model projector. If you need to add a new field (e.g., cancelledAt timestamp), you replay all historical events through a new projector — no migration, no backfill. This is why the pure-function FSM and event sourcing are natural partners.
Example 24: State Machine Visualisation (Generating a DOT Graph)
A data-driven transition table can generate its own state diagram — no manual diagram maintenance.
// => Java: generate a Graphviz DOT graph by iterating the transition table
// => DOT is the input language for Graphviz — also accepted by many web renderers
public class DotGraphDemo {
// => generateDOT: converts the transition map into a DOT digraph string
// => Each (fromState, event) -> toState entry becomes one directed edge
public static String generateDOT(
Map<POState, Map<String, POState>> table, String title) {
StringBuilder sb = new StringBuilder();
sb.append("digraph \"").append(title).append("\" {\n");
// => digraph: directed graph — arrows point from source to target state
sb.append(" rankdir=LR;\n");
// => Left-to-right layout: matches the natural left-to-right process flow
sb.append(" node [shape=box, style=rounded];\n");
// => Rounded rectangles visually distinguish states from edges
for (var fromEntry : table.entrySet()) {
POState from = fromEntry.getKey();
// => Outer key: the source state for this group of transitions
for (var eventEntry : fromEntry.getValue().entrySet()) {
String event = eventEntry.getKey();
POState to = eventEntry.getValue();
// => Inner key: event label; value: target state
sb.append(" \"")
.append(from.name()).append("\" -> \"")
.append(to.name())
.append("\" [label=\"").append(event).append("\"];\n");
// => One DOT edge per (state, event) pair
}
}
sb.append("}");
return sb.toString();
// => Complete DOT string ready for Graphviz or online renderer
}
public static void main(String[] args) {
String dot = generateDOT(PO_TRANSITIONS, "PurchaseOrder FSM");
// => Print first 5 lines to show structure without full output
dot.lines().limit(5).forEach(System.out::println);
// => Output:
// => digraph "PurchaseOrder FSM" {
// => rankdir=LR;
// => node [shape=box, style=rounded];
// => "DRAFT" -> "AWAITING_APPROVAL" [label="submit"];
// => "DRAFT" -> "CANCELLED" [label="cancel"];
}
}Key Takeaway: Generating diagrams from the transition table guarantees the diagram matches the code — they cannot diverge because the diagram is derived from the code.
Why It Matters: Manually maintained state diagrams drift from implementation over time. Generating the diagram from the table inverts the relationship: the code is the specification, and the diagram is a human-readable rendering of it. Every PR that changes the transition table automatically changes the diagram — zero maintenance overhead.
Example 25: The PO FSM as a Protocol
The final beginner example reframes the FSM: it is not just state management, it is a communication protocol between buyer, manager, supplier, and finance. The state names are the protocol verbs.
stateDiagram-v2
[*] --> Draft: Buyer creates PO
Draft --> AwaitingApproval: Buyer submits
AwaitingApproval --> Approved: Manager approves
AwaitingApproval --> Cancelled: Manager rejects
Approved --> Issued: Finance issues
Issued --> Acknowledged: Supplier acknowledges
Acknowledged --> Closed: System closes
note right of Draft
Actor: Buyer
end note
note right of AwaitingApproval
Actor: Manager
end note
note right of Issued
Actor: Supplier
end note
classDef draft fill:#0173B2,stroke:#000,color:#fff
classDef waiting fill:#DE8F05,stroke:#000,color:#000
classDef active fill:#029E73,stroke:#000,color:#fff
classDef terminal fill:#CA9161,stroke:#000,color:#fff
class Draft draft
class AwaitingApproval waiting
class Approved,Issued,Acknowledged active
class Closed,Cancelled terminal
// => Java 17+: record carries actor + expectation for one state in the P2P protocol
// => The FSM doubles as a communication protocol: each state names who acts next
public record ProtocolStep(String actor, String expects) {}
public class ProtocolDemo {
// => PO_PROTOCOL: associates every state with the next expected actor and action
// => Each state is a "waiting point" — the system is waiting for a specific actor
private static final Map<POState, ProtocolStep> PO_PROTOCOL = Map.of(
POState.DRAFT, new ProtocolStep("Buyer", "submit the PO for approval"),
// => Buyer drafts; nothing progresses until buyer submits
POState.AWAITING_APPROVAL, new ProtocolStep("Manager", "approve or reject"),
// => Ball in manager's court — system waits for manager action
POState.APPROVED, new ProtocolStep("Finance", "issue PO to supplier"),
// => Finance team must formally issue the approved PO
POState.ISSUED, new ProtocolStep("Supplier", "acknowledge PO receipt"),
// => Supplier must confirm receipt before goods are dispatched
POState.ACKNOWLEDGED, new ProtocolStep("System", "receive goods and close"),
// => Receiving and invoicing workflows take over
POState.CLOSED, new ProtocolStep("None", "terminal — no further action"),
// => All done: closed POs are immutable history
POState.CANCELLED, new ProtocolStep("None", "terminal — no further action"),
// => Abandoned: cancelled POs are also immutable
POState.DISPUTED, new ProtocolStep("Buyer", "resolve or cancel the dispute")
// => Resolution required from buyer/finance before workflow can continue
);
// => protocolStatus: produces a human-readable status line for notifications or dashboards
public static String protocolStatus(POState state) {
ProtocolStep step = PO_PROTOCOL.get(state);
return "[" + state.name() + "] Waiting for " + step.actor() + ": " + step.expects();
// => Format mirrors what an SLA dashboard or email notification would show
}
public static void main(String[] args) {
System.out.println(protocolStatus(POState.AWAITING_APPROVAL));
// => Output: [AWAITING_APPROVAL] Waiting for Manager: approve or reject
System.out.println(protocolStatus(POState.ISSUED));
// => Output: [ISSUED] Waiting for Supplier: acknowledge PO receipt
}
}Key Takeaway: The FSM is a formal description of the P2P workflow protocol — each state is a waiting point, and each transition is a protocol action performed by an identified actor.
Why It Matters: Framing the FSM as a protocol clarifies responsibility. When a PO is stuck in AwaitingApproval, the system knows to send a reminder to the manager — not the buyer, not the supplier. This actor-awareness is also the foundation for building workflow SLA dashboards: "how many POs have been in AwaitingApproval for more than 48 hours?" answers naturally from this model.
Last updated January 30, 2026