Advanced
This advanced tutorial adds the Supplier lifecycle and Payment state machine from the procurement-platform-be domain, then teaches the concepts that turn flat FSMs into statecharts: hierarchical states, parallel regions, history states, FSM persistence, event-sourcing intersection, and actor-model integration. The MurabahaContract machine appears in the final section as an optional Sharia-finance angle.
Supplier Lifecycle FSM (Examples 51-57)
Example 51: Supplier States and Risk-Tier Semantics
A Supplier record tracks vendor approval status. Unlike PurchaseOrder, the Supplier machine has only four states but each state carries meaningful consequences for the purchasing context.
stateDiagram-v2
[*] --> Pending
Pending --> Approved: approve
Pending --> Blacklisted: blacklist
Approved --> Suspended: suspend
Approved --> Blacklisted: blacklist
Suspended --> Approved: reinstate
Suspended --> Blacklisted: blacklist
Blacklisted --> [*]
classDef pending fill:#DE8F05,stroke:#000,color:#000
classDef approved fill:#029E73,stroke:#000,color:#fff
classDef suspended fill:#CC78BC,stroke:#000,color:#fff
classDef blacklisted fill:#CA9161,stroke:#000,color:#fff
class Pending pending
class Approved approved
class Suspended suspended
class Blacklisted blacklisted
import java.util.EnumMap;
import java.util.Map;
import java.util.Optional;
// => Supplier states: sealed via enum — four states, one terminal
enum SupplierState {
PENDING, // => Application received; vetting in progress
APPROVED, // => Cleared for new POs; appears in supplier selection
SUSPENDED, // => Temporarily blocked: no new POs, existing POs continue
BLACKLISTED // => Permanently excluded: existing POs forced to Disputed — terminal
}
// => Supplier events: all valid triggers on this machine
enum SupplierEvent {
APPROVE, // => Vetting passed; supplier cleared
SUSPEND, // => Compliance issue; temporary hold
REINSTATE, // => Issue resolved; supplier restored
BLACKLIST // => Severe breach; permanent exclusion
}
// => Immutable supplier record — Java record guarantees no mutation
record Supplier(String id, String name, SupplierState state) {
// => id format: sup_<uuid>; name is the supplier legal name
}
// => Result type: models success or failure without exceptions
sealed interface Result<T> {
record Ok<T>(T value) implements Result<T> {}
// => Ok wraps a successfully computed value
record Err<T>(String error) implements Result<T> {}
// => Err carries a human-readable failure reason
}
class SupplierFSM {
// => Transition table: EnumMap for type-safe, O(1) lookup
private static final Map<SupplierState, Map<SupplierEvent, SupplierState>> TRANSITIONS =
new EnumMap<>(SupplierState.class);
static {
// => Pending: can only be approved or immediately blacklisted
TRANSITIONS.put(SupplierState.PENDING, new EnumMap<>(Map.of(
SupplierEvent.APPROVE, SupplierState.APPROVED,
SupplierEvent.BLACKLIST, SupplierState.BLACKLISTED
)));
// => Approved: can be suspended (reversible) or blacklisted (permanent)
TRANSITIONS.put(SupplierState.APPROVED, new EnumMap<>(Map.of(
SupplierEvent.SUSPEND, SupplierState.SUSPENDED,
SupplierEvent.BLACKLIST, SupplierState.BLACKLISTED
)));
// => Suspended: can recover (reinstate) or escalate (blacklist)
TRANSITIONS.put(SupplierState.SUSPENDED, new EnumMap<>(Map.of(
SupplierEvent.REINSTATE, SupplierState.APPROVED,
SupplierEvent.BLACKLIST, SupplierState.BLACKLISTED
)));
// => BLACKLISTED: no entry — terminal, all events rejected
}
// => Pure transition function — returns Result, never throws
static Result<Supplier> transition(Supplier sup, SupplierEvent event) {
// => Look up allowed transitions for the current state
SupplierState next = Optional.ofNullable(TRANSITIONS.get(sup.state()))
.map(m -> m.get(event))
.orElse(null);
// => Optional chain: null-safe two-level map lookup
if (next == null) {
return new Result.Err<>(sup.state() + " --" + event + "--> (forbidden)");
// => No valid transition: return descriptive error
}
return new Result.Ok<>(new Supplier(sup.id(), sup.name(), next));
// => Valid transition: return new immutable Supplier with updated state
}
public static void main(String[] args) {
Supplier sup = new Supplier("sup_001", "Acme Supplies Ltd", SupplierState.APPROVED);
// => Start: Approved supplier
Result<Supplier> r1 = transition(sup, SupplierEvent.SUSPEND);
if (r1 instanceof Result.Ok<Supplier> ok) {
System.out.println(ok.value().state()); // => Output: SUSPENDED
}
// => Chain: reinstate from suspended state
Supplier suspended = r1 instanceof Result.Ok<Supplier> o ? o.value() : sup;
Result<Supplier> r2 = transition(suspended, SupplierEvent.REINSTATE);
if (r2 instanceof Result.Ok<Supplier> ok) {
System.out.println(ok.value().state()); // => Output: APPROVED
}
// => Invalid: approve is not valid from Approved
Result<Supplier> r3 = transition(sup, SupplierEvent.APPROVE);
if (r3 instanceof Result.Err<Supplier> err) {
System.out.println(err.error()); // => Output: APPROVED --APPROVE--> (forbidden)
}
}
}Key Takeaway: Even a four-state machine encodes significant business rules — the asymmetry between Suspended (reversible) and Blacklisted (terminal) is the entire compliance enforcement model.
Why It Matters: The distinction between suspended and blacklisted is a legal and audit concern: suspended suppliers can be reinstated after a compliance review, while blacklisted suppliers require a board-level decision to re-engage. Encoding this in the FSM makes the distinction structural — you cannot accidentally reinstate a blacklisted supplier without changing the machine definition.
Example 52: Supplier State Consequences on PO Selection
The Supplier FSM state gates which suppliers are selectable for new POs — a guard function on the purchasing context reads Supplier state.
stateDiagram-v2
state "Supplier Eligibility" as Check {
Pending --> Blocked: supplierEligibleForPO = false
Approved --> Allowed: supplierEligibleForPO = true
Suspended --> Blocked: supplierEligibleForPO = false
Blacklisted --> Blocked: supplierEligibleForPO = false
}
Allowed --> NewPO: PO can be created
Blocked --> Rejected: PO creation rejected
classDef allowed fill:#029E73,stroke:#000,color:#fff
classDef blocked fill:#CA9161,stroke:#000,color:#fff
classDef pending fill:#DE8F05,stroke:#000,color:#000
classDef suspended fill:#CC78BC,stroke:#000,color:#fff
class Approved,Allowed,NewPO allowed
class Pending pending
class Suspended suspended
class Blacklisted,Blocked,Rejected blocked
import java.util.List;
import java.util.stream.Collectors;
// => Guard: can a supplier receive a new PO?
// => Reads supplier state; returns true only for APPROVED
static boolean supplierEligibleForPO(SupplierState state) {
return state == SupplierState.APPROVED;
// => Only APPROVED: PENDING is unvetted, SUSPENDED cannot receive new POs
}
// => Guard: does blacklisting force existing POs to Disputed?
// => Domain rule: transitioning into BLACKLISTED triggers dispute on all open POs
static boolean blacklistingForcesDispute(SupplierState newState, SupplierState oldState) {
return newState == SupplierState.BLACKLISTED && oldState != SupplierState.BLACKLISTED;
// => Only fires on the transition into BLACKLISTED, not if already there
}
// => Blacklist result: supplier plus all PO ids affected by the cascade
record BlacklistResult(Supplier supplier, List<String> affectedPOIds) {}
// => Blacklist with PO cascade — returns Result to avoid exception-based control flow
static Result<BlacklistResult> blacklistSupplier(Supplier sup, List<String> openPOIds) {
if (sup.state() == SupplierState.BLACKLISTED) {
return new Result.Err<>("Supplier " + sup.id() + " is already blacklisted");
// => Idempotent guard: no-op if already blacklisted
}
var r = SupplierFSM.transition(sup, SupplierEvent.BLACKLIST);
// => Attempt the FSM transition first
if (r instanceof Result.Err<Supplier> err) {
return new Result.Err<>(err.error());
// => Transition failed: propagate the error up
}
var ok = (Result.Ok<Supplier>) r;
// => Transition succeeded: compute which POs are affected
var affected = blacklistingForcesDispute(ok.value().state(), sup.state())
? openPOIds
: List.<String>of();
// => If transitioning into BLACKLISTED: all open POs for this supplier are affected
return new Result.Ok<>(new BlacklistResult(ok.value(), affected));
}
// Demo
System.out.println(supplierEligibleForPO(SupplierState.APPROVED)); // => Output: true
System.out.println(supplierEligibleForPO(SupplierState.SUSPENDED)); // => Output: false
System.out.println(supplierEligibleForPO(SupplierState.PENDING)); // => Output: false
var sup = new Supplier("sup_002", "Beta Corp", SupplierState.APPROVED);
var openPOs = List.of("po_101", "po_102", "po_103");
var bl = blacklistSupplier(sup, openPOs);
if (bl instanceof Result.Ok<BlacklistResult> ok) {
System.out.println(ok.value().supplier().state()); // => Output: BLACKLISTED
System.out.println(ok.value().affectedPOIds().size()); // => Output: 3
}Key Takeaway: Cross-machine effects (blacklisting a supplier forces all their open POs to Disputed) are encoded as explicit functions that return the affected entity IDs — the caller is responsible for applying the cascading changes.
Why It Matters: Cascading state changes across aggregates must be explicit, not implicit. If the blacklist function automatically mutated POs inside itself, it would be doing two different aggregate's work, violating aggregate boundaries. Returning affectedPOIds lets the application service handle each PO transition independently — maintaining clear ownership boundaries.
Example 53: Supplier Risk Score Guard
Supplier approval might require a minimum risk score. A numeric guard on the approve transition enforces the risk threshold.
import java.util.ArrayList;
import java.util.List;
// => Supplier application: carries vetting data submitted during onboarding
record SupplierApplication(
String supplierId,
double riskScore, // => 0.0 (high risk) to 1.0 (low risk); from risk engine
boolean hasDocuments // => Required compliance documents submitted?
) {}
// => Minimum approval thresholds — defined once, enforced consistently
record ApprovalThresholds(double minRiskScore, boolean requireDocuments) {}
static final ApprovalThresholds APPROVAL_THRESHOLDS =
new ApprovalThresholds(0.6, true);
// => Below 0.6 risk score: too risky; requireDocuments: must be submitted
// => Guard: is the supplier application sufficient for approval?
// => Returns all failing reasons at once — caller sees the full picture
static List<String> canApproveSupplier(SupplierApplication app) {
var errors = new ArrayList<String>();
if (app.riskScore() < APPROVAL_THRESHOLDS.minRiskScore()) {
errors.add(String.format("Risk score %.2f below minimum %.1f",
app.riskScore(), APPROVAL_THRESHOLDS.minRiskScore()));
// => Risk score too high: supplier not ready for approval
}
if (APPROVAL_THRESHOLDS.requireDocuments() && !app.hasDocuments()) {
errors.add("Required compliance documents not submitted");
// => Missing documents: approval is blocked regardless of risk score
}
return errors;
// => Empty list: guard passes; non-empty: guard fails with all reasons
}
// => Guarded approve transition: FSM guard + business guard combined
static Result<Supplier> approveSupplier(Supplier sup, SupplierApplication app) {
if (sup.state() != SupplierState.PENDING) {
return new Result.Err<>("Supplier " + sup.id() + " is not in PENDING state");
// => FSM guard: approve only valid from PENDING state
}
var errors = canApproveSupplier(app);
if (!errors.isEmpty()) {
return new Result.Err<>("Approval blocked: " + String.join("; ", errors));
// => Business guard: return all blocking reasons in one response
}
return new Result.Ok<>(new Supplier(sup.id(), sup.name(), SupplierState.APPROVED));
// => Both guards pass: transition to APPROVED
}
// Demo
var sup = new Supplier("sup_003", "Gamma Ltd", SupplierState.PENDING);
// => Low risk score and missing documents — both reasons returned at once
var badApp = new SupplierApplication("sup_003", 0.45, false);
var r1 = approveSupplier(sup, badApp);
if (r1 instanceof Result.Err<Supplier> err) {
System.out.println(err.error());
// => Output: Approval blocked: Risk score 0.45 below minimum 0.6; Required compliance documents not submitted
}
// => Good application: passes all guards
var goodApp = new SupplierApplication("sup_003", 0.75, true);
var r2 = approveSupplier(sup, goodApp);
if (r2 instanceof Result.Ok<Supplier> ok) {
System.out.println(ok.value().state()); // => Output: APPROVED
}Key Takeaway: Multi-criteria guards return all failing reasons at once — the supplier vetting officer sees the complete list of issues, not just the first one.
Why It Matters: In supplier onboarding, a blocked application needs to be actioned by a compliance officer who may be in a different team from the risk analyst. Returning all blocking reasons in one response means both teams can work in parallel on their respective issues rather than discovering problems sequentially.
Example 54: Hierarchical States — Supplier with Sub-States
The Approved state can have sub-states representing tier levels (PreferredVendor, StandardVendor). Hierarchical states model this without duplicating the Approved → Suspended and Approved → Blacklisted transitions for each tier.
stateDiagram-v2
[*] --> Pending
Pending --> Approved
state Approved {
[*] --> Standard
Standard --> Preferred: upgrade
Preferred --> Standard: downgrade
}
Approved --> Suspended: suspend
Approved --> Blacklisted: blacklist
Suspended --> Approved: reinstate
Suspended --> Blacklisted: blacklist
Blacklisted --> [*]
classDef pending fill:#DE8F05,stroke:#000,color:#000
classDef approved fill:#029E73,stroke:#000,color:#fff
classDef suspended fill:#CC78BC,stroke:#000,color:#fff
classDef blacklisted fill:#CA9161,stroke:#000,color:#fff
class Pending pending
class Approved,Standard,Preferred approved
class Suspended suspended
class Blacklisted blacklisted
import java.util.EnumMap;
import java.util.Map;
import java.util.Optional;
// => Hierarchical supplier state: enum encodes parent and sub-states
// => Dot-notation naming (APPROVED_STANDARD) mirrors logical hierarchy
enum HierarchicalSupplierState {
PENDING, // => Application received; no tier yet
APPROVED_STANDARD, // => Regular approved supplier — default entry sub-state
APPROVED_PREFERRED,// => High-volume supplier; negotiated pricing terms
SUSPENDED, // => Temporarily blocked; no new POs
BLACKLISTED // => Permanently excluded — terminal
}
// => Events for the hierarchical machine: includes tier-promotion events
enum HierarchicalEvent {
APPROVE, // => Pending -> Approved.Standard
SUSPEND, // => Any Approved -> Suspended (parent transition)
REINSTATE, // => Suspended -> Approved.Standard
BLACKLIST, // => Any Approved or Suspended -> Blacklisted (parent)
PROMOTE, // => Approved.Standard -> Approved.Preferred (sub-state)
DEMOTE // => Approved.Preferred -> Approved.Standard (sub-state)
}
class HierarchicalFSM {
// => Helper: true if state is within the Approved parent group
static boolean isApproved(HierarchicalSupplierState state) {
return state == HierarchicalSupplierState.APPROVED_STANDARD ||
state == HierarchicalSupplierState.APPROVED_PREFERRED;
// => Both sub-states belong to the "Approved" parent region
}
// => Parent transitions: apply to ALL Approved sub-states
private static final Map<HierarchicalSupplierState,
Map<HierarchicalEvent, HierarchicalSupplierState>> PARENT_TRANSITIONS =
Map.of(
HierarchicalSupplierState.PENDING, Map.of(
HierarchicalEvent.APPROVE, HierarchicalSupplierState.APPROVED_STANDARD,
HierarchicalEvent.BLACKLIST, HierarchicalSupplierState.BLACKLISTED
// => Pending: either approved (enters Standard) or blacklisted
),
HierarchicalSupplierState.SUSPENDED, Map.of(
HierarchicalEvent.REINSTATE, HierarchicalSupplierState.APPROVED_STANDARD,
HierarchicalEvent.BLACKLIST, HierarchicalSupplierState.BLACKLISTED
// => Suspended: recover to Standard or escalate to Blacklisted
)
);
// => Sub-state transitions: only valid within the Approved region
private static final Map<HierarchicalSupplierState,
Map<HierarchicalEvent, HierarchicalSupplierState>> SUBSTATE_TRANSITIONS =
Map.of(
HierarchicalSupplierState.APPROVED_STANDARD, Map.of(
HierarchicalEvent.PROMOTE, HierarchicalSupplierState.APPROVED_PREFERRED
// => Standard -> Preferred: promoted to preferred tier
),
HierarchicalSupplierState.APPROVED_PREFERRED, Map.of(
HierarchicalEvent.DEMOTE, HierarchicalSupplierState.APPROVED_STANDARD
// => Preferred -> Standard: tier reduced (e.g., missed SLA)
)
);
// => Combined transition: parent events take priority over sub-state events
static Optional<HierarchicalSupplierState> transition(
HierarchicalSupplierState state, HierarchicalEvent event) {
// => Parent events (SUSPEND, BLACKLIST) apply to any Approved sub-state
if (isApproved(state) &&
(event == HierarchicalEvent.SUSPEND || event == HierarchicalEvent.BLACKLIST)) {
return Optional.of(event == HierarchicalEvent.SUSPEND
? HierarchicalSupplierState.SUSPENDED
: HierarchicalSupplierState.BLACKLISTED);
// => Parent transition fires regardless of sub-state (Standard or Preferred)
}
// => Try sub-state transitions first, then fall back to parent transitions
var subState = Optional.ofNullable(SUBSTATE_TRANSITIONS.get(state))
.map(m -> m.get(event));
if (subState.isPresent()) return subState;
// => Sub-state transition found: return it
return Optional.ofNullable(PARENT_TRANSITIONS.get(state))
.map(m -> m.get(event));
// => Fall back to parent transitions (e.g., PENDING -> APPROVED_STANDARD)
}
public static void main(String[] args) {
System.out.println(transition(
HierarchicalSupplierState.APPROVED_PREFERRED, HierarchicalEvent.SUSPEND));
// => Output: Optional[SUSPENDED] (parent transition from Preferred)
System.out.println(transition(
HierarchicalSupplierState.APPROVED_STANDARD, HierarchicalEvent.PROMOTE));
// => Output: Optional[APPROVED_PREFERRED] (sub-state transition)
System.out.println(transition(
HierarchicalSupplierState.APPROVED_PREFERRED, HierarchicalEvent.BLACKLIST));
// => Output: Optional[BLACKLISTED] (parent transition even from Preferred)
}
}Key Takeaway: Hierarchical states eliminate transition duplication — parent-level transitions (suspend, blacklist) are defined once and inherited by all sub-states.
Why It Matters: Without hierarchy, Approved.Standard and Approved.Preferred would each need their own suspend and blacklist entries. With five approved sub-states (Standard, Preferred, Strategic, Probation, Trial), the duplication becomes six copies of the same transitions. Hierarchy keeps the machine maintainable as sub-states proliferate.
Example 55: History States — Restoring Previous Sub-State After Suspension
When a supplier is reinstated after suspension, they should return to their previous Approved sub-state (Standard or Preferred), not always to Standard. History states enable this.
stateDiagram-v2
[*] --> Pending
Pending --> Approved
state Approved {
[H] --> Standard: first approval
Standard --> Preferred: upgrade
Preferred --> Standard: downgrade
}
Approved --> Suspended: suspend
Suspended --> Approved: reinstate (restores to [H])
classDef pending fill:#DE8F05,stroke:#000,color:#000
classDef approved fill:#029E73,stroke:#000,color:#fff
classDef suspended fill:#CC78BC,stroke:#000,color:#fff
class Pending pending
class Approved,Standard,Preferred approved
class Suspended suspended
import java.util.Optional;
// => Supplier with history: remembers last Approved sub-state across suspension
record SupplierWithHistory(
String id,
String name,
HierarchicalSupplierState state,
Optional<HierarchicalSupplierState> historyState
// => Optional: empty if never suspended (first approval); present if previously suspended
) {}
// => Suspend: saves current Approved sub-state to history before transitioning
static Result<SupplierWithHistory> suspendWithHistory(SupplierWithHistory sup) {
if (!HierarchicalFSM.isApproved(sup.state())) {
return new Result.Err<>("Cannot suspend: supplier is " + sup.state() + ", not Approved");
// => FSM guard: suspend only valid from an Approved sub-state
}
return new Result.Ok<>(new SupplierWithHistory(
sup.id(),
sup.name(),
HierarchicalSupplierState.SUSPENDED,
Optional.of(sup.state())
// => Save current sub-state to history before moving to SUSPENDED
// => When reinstated, history is restored rather than defaulting to Standard
));
}
// => Reinstate: restores from history state, or defaults to Standard if no history
static Result<SupplierWithHistory> reinstateWithHistory(SupplierWithHistory sup) {
if (sup.state() != HierarchicalSupplierState.SUSPENDED) {
return new Result.Err<>("Cannot reinstate: supplier is " + sup.state());
// => FSM guard: reinstate only valid from SUSPENDED
}
var restoredState = sup.historyState().orElse(HierarchicalSupplierState.APPROVED_STANDARD);
// => History present: restore exactly where they were
// => History absent (null): first-time approval defaults to APPROVED_STANDARD
return new Result.Ok<>(new SupplierWithHistory(
sup.id(),
sup.name(),
restoredState, // => Restored to saved sub-state (or Standard)
Optional.empty() // => Clear history: no longer in suspended state
));
}
// Demo
var preferred = new SupplierWithHistory(
"sup_004", "Delta Corp",
HierarchicalSupplierState.APPROVED_PREFERRED,
Optional.empty() // => First time: no history yet
);
var r1 = suspendWithHistory(preferred);
if (r1 instanceof Result.Ok<SupplierWithHistory> ok1) {
System.out.println(ok1.value().state()); // => Output: SUSPENDED
System.out.println(ok1.value().historyState()); // => Output: Optional[APPROVED_PREFERRED]
var r2 = reinstateWithHistory(ok1.value());
if (r2 instanceof Result.Ok<SupplierWithHistory> ok2) {
System.out.println(ok2.value().state()); // => Output: APPROVED_PREFERRED (restored, not Standard)
System.out.println(ok2.value().historyState()); // => Output: Optional.empty (cleared after reinstatement)
}
}Key Takeaway: History states preserve context across temporary deviations — the machine resumes exactly where it left off, not at some default sub-state.
Why It Matters: Without history, reinstating a preferred supplier would drop them to Standard tier, removing their negotiated pricing terms. With history, the system restores them exactly where they were — the suspension was a temporary hold, not a tier reset. This distinction matters financially: preferred terms often represent negotiated discounts of 10-20%.
Example 56: Supplier FSM with EnumMap — Type-Safe Transition Tables
Enum-keyed maps provide the same sealed-type guarantees across JVM and CLR languages: typos are caught at compile time, not at runtime, and switch/when/pattern-match expressions enforce exhaustiveness.
import java.util.EnumMap;
import java.util.Map;
import java.util.Optional;
// => SupplierState: Java enum — compiler rejects any value outside this set
enum SupplierState {
PENDING, // => Vetting in progress
APPROVED, // => Cleared for POs
SUSPENDED, // => Temporary hold
BLACKLISTED // => Permanent exclusion — terminal
}
// => SupplierEvent: enum alphabet — exhaustiveness enforced by switch expressions
enum SupplierEvent {
APPROVE,
SUSPEND,
REINSTATE,
BLACKLIST
}
// => Immutable supplier: Java record — no setters, structural equality
record Supplier(String id, String name, SupplierState state) {}
class SupplierEnumFSM {
// => EnumMap: backed by array indexed by ordinal — O(1) lookup, type-safe keys
private static final Map<SupplierState, Map<SupplierEvent, SupplierState>> TRANSITIONS =
new EnumMap<>(SupplierState.class);
static {
// => PENDING: vetting passed -> APPROVED; severe breach -> BLACKLISTED directly
TRANSITIONS.put(SupplierState.PENDING, new EnumMap<>(Map.of(
SupplierEvent.APPROVE, SupplierState.APPROVED,
SupplierEvent.BLACKLIST, SupplierState.BLACKLISTED
)));
// => APPROVED: compliance issue -> SUSPENDED; severe breach -> BLACKLISTED
TRANSITIONS.put(SupplierState.APPROVED, new EnumMap<>(Map.of(
SupplierEvent.SUSPEND, SupplierState.SUSPENDED,
SupplierEvent.BLACKLIST, SupplierState.BLACKLISTED
)));
// => SUSPENDED: issue resolved -> APPROVED; escalation -> BLACKLISTED
TRANSITIONS.put(SupplierState.SUSPENDED, new EnumMap<>(Map.of(
SupplierEvent.REINSTATE, SupplierState.APPROVED,
SupplierEvent.BLACKLIST, SupplierState.BLACKLISTED
)));
// => BLACKLISTED: no entry — terminal, all events rejected at lookup
}
// => applyEvent: two-level EnumMap lookup — type-safe, O(1), no string comparison
static Optional<Supplier> applyEvent(Supplier supplier, SupplierEvent event) {
return Optional.ofNullable(TRANSITIONS.get(supplier.state()))
// => First level: get transitions for current state
.map(m -> m.get(event))
// => Second level: get target state for this event
.map(next -> new Supplier(supplier.id(), supplier.name(), next));
// => Valid transition: return new immutable Supplier
}
public static void main(String[] args) {
var sup = new Supplier("sup_005", "Epsilon Ltd", SupplierState.PENDING);
// => Approve: valid from PENDING
var newSup = applyEvent(sup, SupplierEvent.APPROVE);
newSup.ifPresent(s -> System.out.println(s.state())); // => Output: APPROVED
// => Approve again: invalid from APPROVED — Optional.empty()
var invalid = applyEvent(newSup.orElse(sup), SupplierEvent.APPROVE);
System.out.println(invalid.isPresent()); // => Output: false
// => Blacklist: terminal — no reinstatement possible
var blSup = applyEvent(newSup.orElse(sup), SupplierEvent.BLACKLIST);
blSup.ifPresent(s -> System.out.println(s.state())); // => Output: BLACKLISTED
// => Reinstate from BLACKLISTED: invalid — Optional.empty()
var noReinstate = blSup.flatMap(s -> applyEvent(s, SupplierEvent.REINSTATE));
System.out.println(noReinstate.isPresent()); // => Output: false (terminal state)
}
}Key Takeaway: Enum-keyed maps avoid magic strings entirely — the transition table keys are enum members, so typos are caught at compile time rather than at runtime.
Why It Matters: Dictionaries with string keys ({"approved": {"suspend": "suspended"}}) are common but fragile: "APPROVED" vs "approved" silently misses the key. Each language enforces this structurally: enum-keyed maps in Java, Kotlin, and C# reject undefined members at compile time; TypeScript's string literal union type rejects any string outside the declared set at the type-check boundary — the same compile-time safety guarantee regardless of the mechanism.
Example 57: Supplier Blacklisting Cascade — Forcing POs to Disputed
When a supplier is blacklisted, the domain rule requires all their open POs to be forced into the Disputed state. This example implements the cascade as a pure function.
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
// => Open PO record: minimal shape needed for the cascade computation
record OpenPO(String id, String supplierId, String state) {
// => state is String to avoid importing full POState; blacklist cascade only needs the name
}
// => Cascade result: the blacklisted supplier plus classified PO lists
record BlacklistCascade(
Supplier blacklistedSupplier, // => Supplier now in BLACKLISTED state
List<OpenPO> posToDispute, // => These POs must be transitioned to Disputed by the caller
List<OpenPO> posAlreadyClosed // => Already terminal; no action needed
) {}
class BlacklistCascadeService {
// => Terminal PO states: these POs are unaffected by supplier blacklisting
// => Set for O(1) membership check — important for large PO lists
private static final Set<String> TERMINAL_PO_STATES =
Set.of("Closed", "Cancelled", "Paid");
// => computeBlacklistCascade: pure function — no mutations, no side effects
static Result<BlacklistCascade> computeBlacklistCascade(
Supplier sup, List<OpenPO> openPOs) {
// => Step 1: attempt FSM transition to BLACKLISTED
var r = SupplierFSM.transition(sup, SupplierEvent.BLACKLIST);
if (r instanceof Result.Err<Supplier> err) {
return new Result.Err<>(err.error());
// => Transition failed (e.g., already BLACKLISTED): propagate error
}
var blacklisted = ((Result.Ok<Supplier>) r).value();
// => FSM transition succeeded: supplier is now BLACKLISTED
// => Step 2: filter to this supplier's POs only
var supplierPOs = openPOs.stream()
.filter(po -> po.supplierId().equals(sup.id()))
.collect(Collectors.toList());
// => Other suppliers' POs are unaffected
// => Step 3: partition by terminal state membership
var posToDispute = supplierPOs.stream()
.filter(po -> !TERMINAL_PO_STATES.contains(po.state()))
.collect(Collectors.toList());
// => Non-terminal POs: must be disputed by the application service
var posAlreadyClosed = supplierPOs.stream()
.filter(po -> TERMINAL_PO_STATES.contains(po.state()))
.collect(Collectors.toList());
// => Terminal POs: already complete, caller can ignore these
return new Result.Ok<>(new BlacklistCascade(blacklisted, posToDispute, posAlreadyClosed));
// => Pure result: caller applies posToDispute transitions independently
}
public static void main(String[] args) {
var sup = new Supplier("sup_006", "Zeta Ltd", SupplierState.APPROVED);
var pos = List.of(
new OpenPO("po_201", "sup_006", "Acknowledged"), // => Must dispute
new OpenPO("po_202", "sup_006", "Issued"), // => Must dispute
new OpenPO("po_203", "sup_006", "Closed"), // => Terminal: skip
new OpenPO("po_204", "sup_007", "Approved") // => Different supplier: ignore
);
var cascade = computeBlacklistCascade(sup, pos);
if (cascade instanceof Result.Ok<BlacklistCascade> ok) {
System.out.println(ok.value().blacklistedSupplier().state());
// => Output: BLACKLISTED
System.out.println(ok.value().posToDispute().stream()
.map(OpenPO::id).collect(Collectors.toList()));
// => Output: [po_201, po_202]
System.out.println(ok.value().posAlreadyClosed().stream()
.map(OpenPO::id).collect(Collectors.toList()));
// => Output: [po_203]
}
}
}Key Takeaway: Cross-aggregate cascade effects are computed as pure functions returning IDs — the application service applies each change using the individual aggregate's own FSM.
Why It Matters: Applying the cascade inside computeBlacklistCascade would violate aggregate boundaries. Returning posToDispute as IDs keeps the function a pure computation — no side effects, easily testable. The application service then loops over the IDs and calls disputePO for each — each PO's FSM enforces its own invariants independently.
Payment State Machine (Examples 58-64)
Example 58: Payment States and the Disbursement Lifecycle
The Payment aggregate models the financial leg of the P2P cycle: a payment is scheduled, disbursed to the supplier's bank account, remitted (supplier confirms receipt), and potentially fails or is reversed.
stateDiagram-v2
[*] --> Scheduled
Scheduled --> Disbursed: disburse
Disbursed --> Remitted: remit
Disbursed --> Failed: fail
Failed --> Scheduled: retry
Remitted --> [*]
Scheduled --> Reversed: reverse
Disbursed --> Reversed: reverse
classDef scheduled fill:#0173B2,stroke:#000,color:#fff
classDef disbursed fill:#DE8F05,stroke:#000,color:#000
classDef remitted fill:#029E73,stroke:#000,color:#fff
classDef failed fill:#CC78BC,stroke:#000,color:#fff
classDef reversed fill:#CA9161,stroke:#000,color:#fff
class Scheduled scheduled
class Disbursed disbursed
class Remitted remitted
class Failed failed
class Reversed reversed
import java.util.EnumMap;
import java.util.Map;
import java.util.Optional;
// => Payment state: sealed via enum — five distinct lifecycle phases
enum PaymentState {
SCHEDULED, // => Payment run queued; amount and bank details confirmed
DISBURSED, // => Bank API call made; funds in transit
REMITTED, // => Supplier confirmed receipt — terminal success state
FAILED, // => Bank rejected or timed out; retry is eligible
REVERSED // => Payment recalled before or after disbursement — terminal
}
// => Payment event: all valid triggers in the disbursement machine
enum PaymentEvent {
DISBURSE, // => Bank API initiates the wire transfer
REMIT, // => Supplier confirms receipt of funds
FAIL, // => Bank reports failure (timeout, wrong IBAN, insufficient funds)
RETRY, // => Re-queue failed payment for next payment run
REVERSE // => Recall the payment; valid from Scheduled or Disbursed
}
// => Immutable payment record — Java record enforces no mutation after construction
record Payment(
String id, // => Format: pay_<uuid>
String invoiceId, // => Links to Invoice in ScheduledForPayment state
double amount, // => USD amount to disburse
String bankAccount, // => Supplier IBAN in ISO 13616 format
PaymentState state // => Current lifecycle phase
) {}
// => Result type: models success or failure without exceptions
sealed interface Result<T> {
record Ok<T>(T value) implements Result<T> {}
// => Ok wraps a successfully computed value
record Err<T>(String error) implements Result<T> {}
// => Err carries a human-readable failure reason
}
class PaymentFSM {
// => Transition table: EnumMap for O(1) type-safe lookup — no string key typos
private static final Map<PaymentState, Map<PaymentEvent, PaymentState>> TRANSITIONS =
new EnumMap<>(PaymentState.class);
static {
// => Scheduled: wire goes out (disburse) or cancelled before run (reverse)
TRANSITIONS.put(PaymentState.SCHEDULED, new EnumMap<>(Map.of(
PaymentEvent.DISBURSE, PaymentState.DISBURSED,
PaymentEvent.REVERSE, PaymentState.REVERSED
)));
// => Disbursed: three outcomes — supplier confirms, bank fails, or payment recalled
TRANSITIONS.put(PaymentState.DISBURSED, new EnumMap<>(Map.of(
PaymentEvent.REMIT, PaymentState.REMITTED,
PaymentEvent.FAIL, PaymentState.FAILED,
PaymentEvent.REVERSE, PaymentState.REVERSED
)));
// => Failed: only retry — re-enters Scheduled for the next payment run batch
TRANSITIONS.put(PaymentState.FAILED, new EnumMap<>(Map.of(
PaymentEvent.RETRY, PaymentState.SCHEDULED
)));
// => REMITTED, REVERSED: terminal — absent from table; all events rejected
}
// => Pure transition function — returns Result, never throws
static Result<Payment> apply(Payment pmt, PaymentEvent event) {
// => Two-level EnumMap lookup: null-safe via Optional chain
PaymentState next = Optional.ofNullable(TRANSITIONS.get(pmt.state()))
.map(m -> m.get(event))
.orElse(null);
// => null means no valid transition exists for this state+event pair
if (next == null) {
return new Result.Err<>(pmt.state() + " --" + event + "--> (forbidden)");
// => Descriptive error: caller sees the rejected state+event combination
}
return new Result.Ok<>(new Payment(
pmt.id(), pmt.invoiceId(), pmt.amount(), pmt.bankAccount(), next));
// => Valid transition: return new immutable Payment with updated state
}
public static void main(String[] args) {
Payment pmt = new Payment(
"pay_001", "inv_007", 5000.0,
"GB29NWBK60161331926819", PaymentState.SCHEDULED);
// => Start: payment queued for next disbursement run
Result<Payment> r1 = apply(pmt, PaymentEvent.DISBURSE);
if (r1 instanceof Result.Ok<Payment> ok1) {
System.out.println(ok1.value().state()); // => Output: DISBURSED
}
// => Simulate bank failure after wire is sent
Payment disbursed = r1 instanceof Result.Ok<Payment> o ? o.value() : pmt;
Result<Payment> r2 = apply(disbursed, PaymentEvent.FAIL);
if (r2 instanceof Result.Ok<Payment> ok2) {
System.out.println(ok2.value().state()); // => Output: FAILED
}
// => Retry: re-queue for the next scheduled payment run
Payment failed = r2 instanceof Result.Ok<Payment> o ? o.value() : disbursed;
Result<Payment> r3 = apply(failed, PaymentEvent.RETRY);
if (r3 instanceof Result.Ok<Payment> ok3) {
System.out.println(ok3.value().state()); // => Output: SCHEDULED (back to queue)
}
}
}Key Takeaway: The Failed → Scheduled self-retry loop models the payment run retry policy — a payment can attempt disbursement multiple times before being manually reversed.
Why It Matters: Bank APIs fail. Network timeouts, insufficient funds, and format errors are normal operational events. Modelling Failed as a non-terminal state with a retry transition makes the retry policy explicit in the FSM — the system can automatically re-queue payments without human intervention, and the audit trail records each attempt separately.
Example 59: Payment Retry Limit Guard
Without a retry limit, a payment could cycle through Failed → Scheduled → Disbursed → Failed indefinitely. A retry counter guards the retry transition.
import java.util.Optional;
// => Extended payment: adds retry counter and policy maximum on top of base Payment
record PaymentWithRetries(
String id,
String invoiceId,
double amount,
String bankAccount,
PaymentState state,
int retryCount, // => How many times this payment has been retried so far
int maxRetries // => Policy maximum — typically 3 per payment run policy
) {}
// => Guard: can this payment be retried given current retry count?
static boolean canRetry(PaymentWithRetries pmt) {
return pmt.retryCount() < pmt.maxRetries();
// => Below limit: retry allowed; at or above limit: must reverse or escalate manually
}
// => Guarded retry transition — two guards combine: FSM guard + policy guard
static Result<PaymentWithRetries> retryPayment(PaymentWithRetries pmt) {
if (pmt.state() != PaymentState.FAILED) {
return new Result.Err<>(
"Cannot retry payment in state " + pmt.state());
// => FSM guard: retry is only valid from FAILED state
}
if (!canRetry(pmt)) {
return new Result.Err<>(
"Payment " + pmt.id() + " exceeded retry limit (" +
pmt.maxRetries() + "); manual reversal required");
// => Policy guard: too many retries — escalate to finance team for review
}
return new Result.Ok<>(new PaymentWithRetries(
pmt.id(), pmt.invoiceId(), pmt.amount(), pmt.bankAccount(),
PaymentState.SCHEDULED, // => Back to payment queue for next run
pmt.retryCount() + 1, // => Increment the retry counter
pmt.maxRetries() // => Policy maximum unchanged
));
}
// Demo
PaymentWithRetries failedPmt = new PaymentWithRetries(
"pay_002", "inv_008", 3000.0, "GB29NWBK60161331926819",
PaymentState.FAILED, 2, 3);
// => retryCount=2, maxRetries=3 — one retry remaining
Result<PaymentWithRetries> r1 = retryPayment(failedPmt);
if (r1 instanceof Result.Ok<PaymentWithRetries> ok1) {
System.out.println(ok1.value().state() + ", retries: " + ok1.value().retryCount());
// => Output: SCHEDULED, retries: 3
// => r1.value is in SCHEDULED, not FAILED: FSM guard fires on next retry
Result<PaymentWithRetries> r2 = retryPayment(ok1.value());
if (r2 instanceof Result.Err<PaymentWithRetries> err) {
System.out.println(err.error());
// => Output: Cannot retry payment in state SCHEDULED
}
}
// => Simulate at-limit: retryCount already equals maxRetries
PaymentWithRetries atLimit = new PaymentWithRetries(
"pay_002", "inv_008", 3000.0, "GB29NWBK60161331926819",
PaymentState.FAILED, 3, 3);
// => retryCount=3, maxRetries=3 — limit reached
Result<PaymentWithRetries> r3 = retryPayment(atLimit);
if (r3 instanceof Result.Err<PaymentWithRetries> err) {
System.out.println(err.error());
// => Output: Payment pay_002 exceeded retry limit (3); manual reversal required
}Key Takeaway: Retry limits are policy guards on the FSM, not operational logic in a cron job — the machine enforces the limit declaratively, and the cron job just sends events.
Why It Matters: Without a retry limit in the FSM, a misconfigured payment (wrong IBAN) would retry forever, generating bank API calls and audit entries indefinitely. The limit guard ensures the machine itself stops the loop — no additional circuit-breaker logic needed in the payment worker.
Example 60: Parallel Regions — Payment + Notification
A payment disbursement has two parallel concerns: the financial transfer and the supplier notification. Parallel regions model these as concurrent sub-machines that must both complete before the parent advances.
stateDiagram-v2
[*] --> Disbursing
state Disbursing {
[*] --> Transferring
[*] --> Notifying
state Transferring {
[*] --> Pending
Pending --> Sent: initiate
Sent --> Confirmed: bank_ack
Sent --> Failed: bank_error
}
state Notifying {
[*] --> Queued
Queued --> Delivered: delivered
Queued --> Failed: notify_error
}
}
Disbursing --> Completed: both confirmed
Disbursing --> PartiallyFailed: one failed
classDef disbursing fill:#0173B2,stroke:#000,color:#fff
classDef completed fill:#029E73,stroke:#000,color:#fff
classDef failed fill:#CA9161,stroke:#000,color:#fff
class Disbursing,Transferring,Notifying disbursing
class Completed completed
class PartiallyFailed,Failed failed
// => Transfer sub-region states: financial leg of parallel disbursement
enum TransferState { PENDING, SENT, CONFIRMED, FAILED }
// => CONFIRMED is terminal success; FAILED is terminal failure for this region
// => Notification sub-region states: supplier notification leg
enum NotificationState { QUEUED, SENT, DELIVERED, FAILED }
// => DELIVERED is terminal success; FAILED is terminal failure for this region
// => Combined parallel state: both regions tracked as a single immutable record
record ParallelPaymentState(
TransferState transfer, // => Financial wire transfer leg
NotificationState notification // => Supplier email/webhook notification leg
) {}
// => Overall parallel payment outcome: derived from both region states
enum ParallelOutcome {
DISBURSING, // => At least one region is still in progress
COMPLETED, // => Both regions reached their success terminals
PARTIALLY_FAILED, // => Exactly one region failed — needs investigation
FAILED // => Both regions failed — complete failure
}
// => Pure region updaters: return new ParallelPaymentState, leaving the other region unchanged
static ParallelPaymentState updateTransfer(
ParallelPaymentState regions, TransferState outcome) {
return new ParallelPaymentState(outcome, regions.notification());
// => Only the transfer field changes; notification field is preserved exactly
}
static ParallelPaymentState updateNotification(
ParallelPaymentState regions, NotificationState outcome) {
return new ParallelPaymentState(regions.transfer(), outcome);
// => Only the notification field changes; transfer field is preserved exactly
}
// => Evaluate the combined outcome from both region states — pure function
static ParallelOutcome evaluateParallelState(ParallelPaymentState regions) {
boolean transferDone = regions.transfer() == TransferState.CONFIRMED;
boolean notificationDone = regions.notification() == NotificationState.DELIVERED;
boolean transferFailed = regions.transfer() == TransferState.FAILED;
boolean notificationFailed = regions.notification() == NotificationState.FAILED;
// => Evaluate each terminal condition independently before combining
if (transferDone && notificationDone) return ParallelOutcome.COMPLETED;
// => Both succeeded: payment fully complete — advance the parent machine
if (transferFailed && notificationFailed) return ParallelOutcome.FAILED;
// => Both failed: complete failure — route to payment failure handling
if (transferFailed || notificationFailed) return ParallelOutcome.PARTIALLY_FAILED;
// => One failed: partial failure — alert ops team for investigation
return ParallelOutcome.DISBURSING;
// => Neither region in terminal state: still in progress
}
// Demo — simulate parallel execution
ParallelPaymentState regions = new ParallelPaymentState(
TransferState.PENDING, NotificationState.QUEUED);
// => Initial state: both regions just started
regions = updateTransfer(regions, TransferState.SENT);
regions = updateNotification(regions, NotificationState.SENT);
System.out.println(evaluateParallelState(regions));
// => Output: DISBURSING (neither region at terminal state yet)
regions = updateTransfer(regions, TransferState.CONFIRMED);
System.out.println(evaluateParallelState(regions));
// => Output: DISBURSING (transfer confirmed but notification not yet delivered)
regions = updateNotification(regions, NotificationState.DELIVERED);
System.out.println(evaluateParallelState(regions));
// => Output: COMPLETED (both regions reached their terminal success states)Key Takeaway: Parallel regions track independent progress across concurrent concerns — the parent state advances only when all regions reach their terminal sub-states.
Why It Matters: Financial transfers and supplier notifications have different timing and failure modes. The bank API might succeed while the SMTP server is down. Without parallel regions, you need ad-hoc flags to track partial completion. With parallel regions, the structure makes it explicit: the payment is Disbursing until both regions complete, and PartiallyFailed if one fails — actionable, not ambiguous.
Example 61: FSM Persistence — Serialising State to JSON
FSM state must survive process restarts. Serialising to JSON and deserialising back into the typed state record makes persistence straightforward.
stateDiagram-v2
state "In-Memory FSM State" as Memory {
[*] --> TypedState
TypedState --> TypedState: transition()
}
state "Persistent Storage" as Store {
[*] --> JSONSnapshot
JSONSnapshot --> JSONSnapshot: upsert on transition
}
Memory --> Store: serialise() on each transition
Store --> Memory: deserialise() on process restart
classDef memory fill:#0173B2,stroke:#000,color:#fff
classDef store fill:#029E73,stroke:#000,color:#fff
class TypedState memory
class JSONSnapshot store
import java.time.Instant;
import java.util.Arrays;
import java.util.Optional;
// => Payment snapshot: plain serialisable record — no enum types, no object references
record PaymentSnapshot(
String id,
String invoiceId,
double amount,
String bankAccount,
String state, // => Stored as String in JSON; validated on deserialise
int retryCount,
int maxRetries,
String savedAt // => ISO 8601 timestamp: when this snapshot was written
) {}
// => Serialise: PaymentWithRetries → snapshot (String state for JSON safety)
static PaymentSnapshot serialisePayment(PaymentWithRetries pmt) {
return new PaymentSnapshot(
pmt.id(), pmt.invoiceId(), pmt.amount(), pmt.bankAccount(),
pmt.state().name(), // => Enum.name() → "FAILED", "SCHEDULED", etc.
pmt.retryCount(), pmt.maxRetries(),
Instant.now().toString() // => ISO 8601 UTC timestamp for the snapshot
);
// => All fields are primitives or Strings: safe for JSON, database, or message bus
}
// => Deserialise: snapshot → PaymentWithRetries (with boundary validation)
static Result<PaymentWithRetries> deserialisePayment(PaymentSnapshot snap) {
// => Validate state string against the known enum values before construction
Optional<PaymentState> maybeState = Arrays.stream(PaymentState.values())
.filter(s -> s.name().equals(snap.state()))
.findFirst();
// => Stream-based enum lookup: null-safe and schema-migration-aware
if (maybeState.isEmpty()) {
return new Result.Err<>(
"Unknown payment state in snapshot: '" + snap.state() + "'");
// => Guard fires for: old schema names, typos, dropped enum members
}
return new Result.Ok<>(new PaymentWithRetries(
snap.id(), snap.invoiceId(), snap.amount(), snap.bankAccount(),
maybeState.get(), // => Validated: safe enum value
snap.retryCount(), snap.maxRetries()
));
// => Reconstruction succeeds: FSM can now operate on a fully typed record
}
// Demo — roundtrip: object → snapshot → back to object
PaymentWithRetries pmt = new PaymentWithRetries(
"pay_003", "inv_009", 2500.0, "GB29NWBK60161331926819",
PaymentState.FAILED, 1, 3);
// => Starting state: FAILED with one retry already used
PaymentSnapshot snap = serialisePayment(pmt);
// => In a real system: JSON.stringify(snap) then store in DB / publish to queue
Result<PaymentWithRetries> r = deserialisePayment(snap);
// => In a real system: load from DB / consume from queue, then deserialise
if (r instanceof Result.Ok<PaymentWithRetries> ok) {
System.out.println(ok.value().state()); // => Output: FAILED
System.out.println(ok.value().retryCount()); // => Output: 1
}
// => Simulate a migration-broken snapshot with an unrecognised state string
PaymentSnapshot badSnap = new PaymentSnapshot(
"pay_003", "inv_009", 2500.0, "GB29NWBK60161331926819",
"failed", // => v1 lowercase schema — no longer valid
1, 3, Instant.now().toString());
Result<PaymentWithRetries> badR = deserialisePayment(badSnap);
if (badR instanceof Result.Err<PaymentWithRetries> err) {
System.out.println(err.error());
// => Output: Unknown payment state in snapshot: 'failed'
}Key Takeaway: Validation on deserialisation catches migration failures at the boundary — the FSM itself never operates on unvalidated state strings.
Why It Matters: Without deserialisation validation, a database containing "failed" (lowercase, from a v1 schema) would crash the FSM at the first event, and the error message would be unhelpful. Validating at the boundary produces a clear error: "Unknown payment state: 'failed'" — immediately actionable as a migration task.
Example 62: Event Sourcing Intersection — Rebuilding Payment from Events
Storing events instead of state means the Payment record is always rebuildable from its event log.
import java.util.List;
// => Stored event: one entry per successful FSM transition, in strict chronological order
record StoredPaymentEvent(
String eventId, // => Unique event ID for deduplication on replay
String paymentId, // => Which payment aggregate this event belongs to
PaymentEvent event, // => The typed event enum — guarantees only valid events are stored
String timestamp // => ISO 8601 wall-clock time when the transition occurred
) {}
// => Rebuild payment state by replaying the stored event log — pure fold operation
static Result<Payment> rebuildPayment(Payment initial, List<StoredPaymentEvent> events) {
Payment current = initial;
// => Start from the initial state (or from a snapshot if skipping early events)
for (StoredPaymentEvent stored : events) {
Result<Payment> r = PaymentFSM.apply(current, stored.event());
// => Apply each stored event in sequence via the same FSM transition function
if (r instanceof Result.Err<Payment> err) {
return new Result.Err<>(
"Replay failed at event '" + stored.event() + "': " + err.error());
// => Guard: an invalid transition in the stored log signals corruption or
// => a migration gap — the event was valid at storage time but not now
}
current = ((Result.Ok<Payment>) r).value();
// => Advance to the next state; next iteration's apply() uses this as input
}
return new Result.Ok<>(current);
// => Final state: the payment as it stood after all stored events
}
// Demo — rebuild from five stored events
Payment initial = new Payment(
"pay_004", "inv_010", 4000.0,
"GB29NWBK60161331926820", PaymentState.SCHEDULED);
// => Initial state: Scheduled (could also come from a persisted snapshot)
List<StoredPaymentEvent> history = List.of(
new StoredPaymentEvent("evt_1", "pay_004", PaymentEvent.DISBURSE, "2026-01-15T09:00:00Z"),
// => SCHEDULED → DISBURSED: bank API called
new StoredPaymentEvent("evt_2", "pay_004", PaymentEvent.FAIL, "2026-01-15T09:05:00Z"),
// => DISBURSED → FAILED: bank timeout
new StoredPaymentEvent("evt_3", "pay_004", PaymentEvent.RETRY, "2026-01-15T14:00:00Z"),
// => FAILED → SCHEDULED: re-queued for afternoon run
new StoredPaymentEvent("evt_4", "pay_004", PaymentEvent.DISBURSE,"2026-01-15T14:30:00Z"),
// => SCHEDULED → DISBURSED: second disbursement attempt succeeds
new StoredPaymentEvent("evt_5", "pay_004", PaymentEvent.REMIT, "2026-01-16T08:00:00Z")
// => DISBURSED → REMITTED: supplier confirms receipt next day
);
Result<Payment> rebuilt = rebuildPayment(initial, history);
if (rebuilt instanceof Result.Ok<Payment> ok) {
System.out.println(ok.value().state()); // => Output: REMITTED
// => Five events replayed: SCHEDULED → DISBURSED → FAILED → SCHEDULED → DISBURSED → REMITTED
}Key Takeaway: A pure-function FSM is a natural event-sourcing projector — the rebuild function is just the transition function applied repeatedly over the event log.
Why It Matters: Event sourcing gives you a complete audit trail of every state a payment has been in, with timestamps. For regulatory purposes (e.g., SAMA, Central Bank requirements), being able to prove exactly when a payment moved from Disbursed to Failed and when it was retried is a compliance requirement. The event log satisfies this without any additional reporting infrastructure.
Example 63: Statechart — Combining Hierarchical + Parallel + History
The Payment statechart uses all three statechart concepts: a hierarchical Active state containing the financial and notification parallel regions, a history state for reinstatement after temporary failure, and a top-level terminal pair.
stateDiagram-v2
[*] --> Active
state Active {
[H] --> Disbursing
state Disbursing {
[*] --> TransferPending
[*] --> NotifyQueued
TransferPending --> TransferConfirmed: bank_ack
NotifyQueued --> NotifyDelivered: notify_ok
}
Disbursing --> TemporarilyFailed: any_failure
TemporarilyFailed --> Disbursing: retry (restores [H])
}
Active --> Completed: both_regions_done
Active --> Reversed: recall
classDef active fill:#0173B2,stroke:#000,color:#fff
classDef completed fill:#029E73,stroke:#000,color:#fff
classDef failed fill:#CC78BC,stroke:#000,color:#fff
classDef terminal fill:#CA9161,stroke:#000,color:#fff
class Active,Disbursing active
class Completed completed
class TemporarilyFailed failed
class Reversed terminal
import java.util.Optional;
// => Statechart state: sealed hierarchy encodes three top-level kinds
sealed interface StatechartPaymentState {
// => Active: payment in progress; owns parallel regions + optional history for recovery
record Active(
ParallelPaymentState regions, // => Current state of both sub-regions
ParallelPaymentState historyRegions // => Saved regions before a failure (null = no history)
) implements StatechartPaymentState {}
// => Completed: both regions reached their terminal success states — immutable singleton
record Completed() implements StatechartPaymentState {}
// => Reversed: payment recalled at the top level — immutable singleton
record Reversed() implements StatechartPaymentState {}
}
// => Events: typed sealed hierarchy for the statechart machine
sealed interface StatechartEvent {
record TransferSent() implements StatechartEvent {}
record TransferConfirmed() implements StatechartEvent {}
record TransferFailed() implements StatechartEvent {}
// => TransferFailed: triggers history save before failing the transfer region
record NotificationSent() implements StatechartEvent {}
record NotificationDelivered() implements StatechartEvent {}
record NotificationFailed() implements StatechartEvent {}
record Reverse() implements StatechartEvent {}
// => Reverse: top-level event — exits Active entirely; valid from any Active sub-state
}
// => Apply event to the statechart — pattern matching on both state and event
static StatechartPaymentState applyStatechartEvent(
StatechartPaymentState state, StatechartEvent event) {
if (!(state instanceof StatechartPaymentState.Active active)) {
return state; // => Terminal states (Completed, Reversed): no further transitions
}
ParallelPaymentState r = active.regions();
// => Dispatch on event type: each branch updates exactly the relevant region field
return switch (event) {
case StatechartEvent.TransferSent e -> new StatechartPaymentState.Active(
updateTransfer(r, TransferState.SENT), active.historyRegions());
case StatechartEvent.TransferConfirmed e -> new StatechartPaymentState.Active(
updateTransfer(r, TransferState.CONFIRMED), active.historyRegions());
// => Transfer confirmed: notification may still be in progress
case StatechartEvent.TransferFailed e -> new StatechartPaymentState.Active(
updateTransfer(r, TransferState.FAILED), r);
// => Transfer failed: save current regions to history before marking failure
case StatechartEvent.NotificationSent e -> new StatechartPaymentState.Active(
updateNotification(r, NotificationState.SENT), active.historyRegions());
case StatechartEvent.NotificationDelivered e -> new StatechartPaymentState.Active(
updateNotification(r, NotificationState.DELIVERED), active.historyRegions());
case StatechartEvent.NotificationFailed e -> new StatechartPaymentState.Active(
updateNotification(r, NotificationState.FAILED), active.historyRegions());
case StatechartEvent.Reverse e -> new StatechartPaymentState.Reversed();
// => Reverse: top-level transition — exits Active entirely regardless of region states
};
}
// => Evaluate if Active should advance to Completed based on both region outcomes
static StatechartPaymentState checkCompletion(StatechartPaymentState state) {
if (!(state instanceof StatechartPaymentState.Active active)) return state;
// => Only Active state can advance; Completed and Reversed are already terminal
ParallelOutcome overall = evaluateParallelState(active.regions());
// => evaluateParallelState checks both region terminal conditions
return overall == ParallelOutcome.COMPLETED
? new StatechartPaymentState.Completed()
: state;
// => Both regions done: advance to Completed; otherwise stay Active
}
// Demo
StatechartPaymentState sc = new StatechartPaymentState.Active(
new ParallelPaymentState(TransferState.PENDING, NotificationState.QUEUED),
null // => No history yet: first run, no prior failure to restore from
);
// => Initial statechart state: Active with both regions just started
sc = applyStatechartEvent(sc, new StatechartEvent.TransferConfirmed());
// => Transfer region advances to CONFIRMED; notification still QUEUED
sc = applyStatechartEvent(sc, new StatechartEvent.NotificationDelivered());
// => Notification region advances to DELIVERED; both regions now at terminal success
sc = checkCompletion(sc);
// => evaluateParallelState returns COMPLETED: advance the statechart to Completed
System.out.println(sc.getClass().getSimpleName()); // => Output: CompletedKey Takeaway: Statecharts — hierarchical + parallel + history — handle real-world complexity that flat FSMs cannot express without state explosion: the three concepts together keep state machines manageable at scale.
Why It Matters: A flat FSM for a payment with two parallel concerns would need one state per combination of (transfer-state × notification-state): 4 × 4 = 16 states. With parallel regions, it is 4 + 4 = 8 sub-states plus a composite parent. As the number of parallel concerns grows, the flat FSM grows quadratically; the statechart grows linearly.
Example 64: Full P2P Machine Coverage Check
A runtime check that verifies all four machines cover their expected states — the machine specification as executable test.
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
// => MachineSpec: expected states per machine from the domain specification document
record MachineSpec(String machineName, List<String> expectedStates) {}
// => CoverageReport: result of comparing expected states against implemented states
record CoverageReport(
List<String> covered, // => States present in both spec and implementation
List<String> missing, // => States in spec but not in implementation — action required
List<String> extra // => States in implementation not in spec — may be undocumented
) {}
// => coverageCheck: pure function — compares spec vs implementation key set
static CoverageReport coverageCheck(
MachineSpec spec, Set<String> implementedStateNames) {
// => implemented = states that appear as KEYS in the transition table
// => Terminal states appear only as VALUES — this is correct by design
List<String> covered = spec.expectedStates().stream()
.filter(implementedStateNames::contains)
.collect(Collectors.toList());
// => Covered: state is in both the spec and the transition table keys
List<String> missing = spec.expectedStates().stream()
.filter(s -> !implementedStateNames.contains(s))
.collect(Collectors.toList());
// => Missing: state is in spec but not in transition table keys
// => Missing non-terminals are defects; missing terminals are expected
List<String> extra = implementedStateNames.stream()
.filter(s -> !spec.expectedStates().contains(s))
.collect(Collectors.toList());
// => Extra: state in implementation but not in spec — may be an undocumented addition
return new CoverageReport(covered, missing, extra);
}
// Demo — check the Payment machine against the domain spec
MachineSpec paymentSpec = new MachineSpec("Payment",
List.of("SCHEDULED", "DISBURSED", "REMITTED", "FAILED", "REVERSED"));
// => 5 states from the locked domain spec
Set<String> implementedKeys = Arrays.stream(PaymentState.values())
.filter(s -> Map.of(
PaymentState.SCHEDULED, true,
PaymentState.DISBURSED, true,
PaymentState.FAILED, true
).containsKey(s))
.map(PaymentState::name)
.collect(Collectors.toSet());
// => Only SCHEDULED, DISBURSED, FAILED appear as keys (have outgoing transitions)
// => REMITTED and REVERSED are terminal — they appear as VALUES only, not KEYS
CoverageReport report = coverageCheck(paymentSpec, implementedKeys);
System.out.println("Payment covered: " + report.covered().size() + "/" +
paymentSpec.expectedStates().size());
// => Output: Payment covered: 3/5
// => REMITTED and REVERSED are correctly missing from table keys (they are terminals)
System.out.println("Missing from table keys: " + report.missing());
// => Output: Missing from table keys: [REMITTED, REVERSED]
// => This is expected: terminal states appear as VALUES, not KEYS, in any FSM table
System.out.println("All spec states accounted for (terminals as values): " +
(report.missing().stream().allMatch(s -> s.equals("REMITTED") || s.equals("REVERSED"))));
// => Output: All spec states accounted for (terminals as values): trueKey Takeaway: A coverage check that runs as code — not a document — catches discrepancies between the domain spec and the implementation before they reach production.
Why It Matters: As the domain spec evolves, the coverage check automatically reports which new states are not yet implemented. This turns a manual audit (compare spec doc to code) into an automated gate that can run in CI — the spec is the test, and the implementation must keep up.
Sharia-Finance Extension (Examples 65-67)
Example 65: MurabahaContract State Machine (Optional)
The MurabahaContract aggregate models a Sharia-compliant financing arrangement where a bank purchases an asset and resells it to the buyer at an agreed markup. It is optional — not every P2P deployment uses murabaha financing.
stateDiagram-v2
[*] --> Quoted
Quoted --> AssetAcquired: acquire_asset
AssetAcquired --> Signed: sign
Signed --> InstallmentPending: first_installment_due
InstallmentPending --> InstallmentPaid: pay_installment
InstallmentPaid --> InstallmentPending: next_installment_due
InstallmentPaid --> Settled: final_installment
InstallmentPending --> Defaulted: default
Settled --> [*]
Defaulted --> [*]
classDef quoted fill:#0173B2,stroke:#000,color:#fff
classDef acquired fill:#DE8F05,stroke:#000,color:#000
classDef signed fill:#029E73,stroke:#000,color:#fff
classDef installment fill:#CC78BC,stroke:#000,color:#fff
classDef terminal fill:#CA9161,stroke:#000,color:#fff
class Quoted quoted
class AssetAcquired acquired
class Signed signed
class InstallmentPending,InstallmentPaid installment
class Settled,Defaulted terminal
import java.util.EnumMap;
import java.util.Map;
import java.util.Optional;
// => MurabahaState: sealed via enum — seven lifecycle phases for Sharia financing
enum MurabahaState {
QUOTED, // => Financing terms proposed by the murabaha bank
ASSET_ACQUIRED, // => Bank has purchased the underlying asset from supplier
SIGNED, // => Buyer and bank signed the murabaha agreement
INSTALLMENT_PENDING, // => Next installment due from buyer
INSTALLMENT_PAID, // => Installment paid; more may follow
SETTLED, // => All installments paid — terminal success
DEFAULTED // => Buyer failed to pay — terminal failure
}
// => MurabahaEvent: all valid triggers in the contract lifecycle
enum MurabahaEvent {
ACQUIRE_ASSET, // => Bank purchases the asset from supplier
SIGN, // => Agreement signed by both parties
FIRST_INSTALLMENT_DUE, // => Payment schedule officially begins
PAY_INSTALLMENT, // => Buyer pays one installment
NEXT_INSTALLMENT_DUE, // => Scheduler triggers next due cycle
FINAL_INSTALLMENT, // => Last installment paid: contract settles
DEFAULT // => Buyer misses payment; contract defaults
}
// => MurabahaMarkup record: basis points where 1 bp = 0.01%
record MurabahaMarkup(int basisPoints) {
// => basisPoints must be > 0 and <= 5000 (max 50% markup per domain spec)
}
// => Immutable contract record — Java record enforces no mutation after construction
record MurabahaContract(
String id, // => Contract reference: format mur_<uuid>
String poId, // => Links to PurchaseOrder being financed
MurabahaMarkup markup, // => Agreed profit margin for the bank
MurabahaState state // => Current lifecycle phase
) {}
// => Result type: models success or failure without exceptions
sealed interface Result<T> {
record Ok<T>(T value) implements Result<T> {}
// => Ok wraps a successfully computed value
record Err<T>(String error) implements Result<T> {}
// => Err carries a human-readable failure reason
}
class MurabahaFSM {
// => Transition table: EnumMap for O(1) type-safe lookup
private static final Map<MurabahaState, Map<MurabahaEvent, MurabahaState>> TRANSITIONS =
new EnumMap<>(MurabahaState.class);
static {
// => QUOTED: only asset acquisition can advance the contract
TRANSITIONS.put(MurabahaState.QUOTED, new EnumMap<>(Map.of(
MurabahaEvent.ACQUIRE_ASSET, MurabahaState.ASSET_ACQUIRED
)));
// => ASSET_ACQUIRED: bank has asset; awaiting buyer signature
TRANSITIONS.put(MurabahaState.ASSET_ACQUIRED, new EnumMap<>(Map.of(
MurabahaEvent.SIGN, MurabahaState.SIGNED
)));
// => SIGNED: agreement in force; schedule begins with first due date
TRANSITIONS.put(MurabahaState.SIGNED, new EnumMap<>(Map.of(
MurabahaEvent.FIRST_INSTALLMENT_DUE, MurabahaState.INSTALLMENT_PENDING
)));
// => INSTALLMENT_PENDING: buyer pays or defaults — no other valid event
TRANSITIONS.put(MurabahaState.INSTALLMENT_PENDING, new EnumMap<>(Map.of(
MurabahaEvent.PAY_INSTALLMENT, MurabahaState.INSTALLMENT_PAID,
MurabahaEvent.DEFAULT, MurabahaState.DEFAULTED
)));
// => INSTALLMENT_PAID: more to come or final one triggers settlement
TRANSITIONS.put(MurabahaState.INSTALLMENT_PAID, new EnumMap<>(Map.of(
MurabahaEvent.NEXT_INSTALLMENT_DUE, MurabahaState.INSTALLMENT_PENDING,
MurabahaEvent.FINAL_INSTALLMENT, MurabahaState.SETTLED
)));
// => SETTLED, DEFAULTED: terminal — absent from table; all events rejected
}
// => Markup guard: validates Sharia-compliant markup before contract creation
static Optional<String> validateMarkup(MurabahaMarkup markup) {
if (markup.basisPoints() <= 0)
return Optional.of("Markup must be > 0 basis points");
// => Zero or negative markup is meaningless and disallowed
if (markup.basisPoints() > 5000)
return Optional.of("Markup exceeds 5000 basis points (50% maximum)");
// => 50% cap enforces Sharia-acceptable range per domain spec
return Optional.empty();
// => Optional.empty(): guard passes, markup is valid
}
// => Pure transition function — returns Result, never throws
static Result<MurabahaContract> apply(MurabahaContract contract, MurabahaEvent event) {
MurabahaState next = Optional.ofNullable(TRANSITIONS.get(contract.state()))
.map(m -> m.get(event))
.orElse(null);
// => Two-level EnumMap lookup: null means no valid transition exists
if (next == null) {
return new Result.Err<>(contract.state() + " --" + event + "--> (forbidden)");
// => Descriptive error: caller sees the rejected state+event pair
}
return new Result.Ok<>(new MurabahaContract(
contract.id(), contract.poId(), contract.markup(), next));
// => Valid transition: return new immutable contract with updated state
}
public static void main(String[] args) {
var markup = new MurabahaMarkup(300);
// => 300 basis points = 3% markup — within Sharia-acceptable range
System.out.println(validateMarkup(markup).isEmpty()); // => Output: true (valid)
System.out.println(validateMarkup(new MurabahaMarkup(6000)).isPresent()); // => Output: true (invalid)
var contract = new MurabahaContract("mur_001", "po_fin_001", markup, MurabahaState.QUOTED);
// => Start: contract terms proposed, awaiting asset acquisition
var r1 = apply(contract, MurabahaEvent.ACQUIRE_ASSET);
if (r1 instanceof Result.Ok<MurabahaContract> ok1)
System.out.println(ok1.value().state()); // => Output: ASSET_ACQUIRED
// => Invalid: cannot sign before asset acquired — machine enforces order
var r2 = apply(contract, MurabahaEvent.SIGN);
if (r2 instanceof Result.Err<MurabahaContract> err)
System.out.println(err.error()); // => Output: QUOTED --SIGN--> (forbidden)
}
}Key Takeaway: The MurabahaContract FSM is structurally identical to PO and Invoice machines — the domain logic is different (installments, Sharia markup), but the sealed-type + transition-table + pure-function pattern is the same.
Why It Matters: This consistency is deliberate. When every aggregate in the system uses the same FSM pattern, new engineers onboard faster — they understand the pattern once and apply it everywhere. The Sharia angle (murabaha markup, installment schedule) is a business concern handled in guards and actions; the FSM infrastructure is reused unchanged.
Example 66: Installment Counter and Self-Loop
The InstallmentPaid → InstallmentPending cycle repeats for each installment. The number of installments completed is tracked in the contract context.
// => Extended contract: record adds installment tracking fields to the base MurabahaContract
record MurabahaContractWithPayments(
String id,
String poId,
MurabahaMarkup markup,
MurabahaState state,
int totalInstallments, // => Total number of installments agreed in the contract
int installmentsPaid, // => How many installments have been completed so far
int installmentAmount // => USD amount per installment period
) {}
// => Pay one installment: FSM guard + counter logic determines which event fires
static Result<MurabahaContractWithPayments> payInstallment(MurabahaContractWithPayments c) {
if (c.state() != MurabahaState.INSTALLMENT_PENDING) {
return new Result.Err<>("Cannot pay installment in state " + c.state());
// => FSM guard: payment only valid when an installment is due
}
int newCount = c.installmentsPaid() + 1;
// => Increment paid count before deciding which event fires
if (newCount >= c.totalInstallments()) {
// => This is the final installment: transition to SETTLED terminal state
return new Result.Ok<>(new MurabahaContractWithPayments(
c.id(), c.poId(), c.markup(),
MurabahaState.SETTLED,
c.totalInstallments(), newCount, c.installmentAmount()
));
}
// => More installments remain: return to INSTALLMENT_PAID; caller fires NEXT_INSTALLMENT_DUE
return new Result.Ok<>(new MurabahaContractWithPayments(
c.id(), c.poId(), c.markup(),
MurabahaState.INSTALLMENT_PAID,
c.totalInstallments(), newCount, c.installmentAmount()
));
}
// Demo: three-installment contract
var c = new MurabahaContractWithPayments(
"mur_002", "po_fin_002", new MurabahaMarkup(250),
MurabahaState.INSTALLMENT_PENDING, 3, 0, 10000
);
// => totalInstallments=3, installmentsPaid=0, $10,000 per installment
for (int i = 0; i < 3; i++) {
var r = payInstallment(c);
if (r instanceof Result.Ok<MurabahaContractWithPayments> ok) {
c = ok.value();
System.out.printf("Paid %d/%d: %s%n",
c.installmentsPaid(), c.totalInstallments(), c.state());
// => i=0: Output: Paid 1/3: INSTALLMENT_PAID
// => i=1: Output: Paid 2/3: INSTALLMENT_PAID
// => i=2: Output: Paid 3/3: SETTLED (final installment settles contract)
// => Simulate NEXT_INSTALLMENT_DUE trigger between cycles
if (c.state() == MurabahaState.INSTALLMENT_PAID) {
c = new MurabahaContractWithPayments(c.id(), c.poId(), c.markup(),
MurabahaState.INSTALLMENT_PENDING,
c.totalInstallments(), c.installmentsPaid(), c.installmentAmount());
// => In production: a scheduler fires this after the billing period elapses
}
}
}Key Takeaway: Installment counters in the FSM context determine which event fires after each payment — the counter bridges the discrete state machine and the continuous payment schedule.
Why It Matters: The installment loop is a common pattern in financing contracts. By tracking the counter alongside the state, the FSM can determine autonomously when to settle — no external scheduler needs to know the total installment count. The scheduler only sends pay_installment events; the FSM decides whether the result is InstallmentPaid or Settled.
Example 67: Linking MurabahaContract to PurchaseOrder
When a PurchaseOrder is financed via murabaha, the PO's payment leg is handled by the MurabahaContract, not the Payment aggregate. The link is a foreign key — the PO carries the contract reference.
import java.util.Optional;
// => FinancedPO: extends base PO with an optional murabaha contract reference
record FinancedPO(
String id,
double totalAmount,
String state, // => Using String to avoid importing POState
Optional<String> murabahaContractId // => Present only for murabaha-financed POs
// => Optional.empty(): conventional procurement path (Payment aggregate)
// => Optional.of("mur_xyz"): Sharia-financed path (MurabahaContract)
) {}
// => Guard: determine the payment path for this PO
static String paymentPath(FinancedPO po) {
return po.murabahaContractId().isPresent() ? "murabaha" : "conventional";
// => Murabaha path: payment via installments on the MurabahaContract
// => Conventional path: Payment aggregate handles Scheduled -> Disbursed -> Remitted
}
// => Advance PO to Paid: guard varies by payment path
static Result<FinancedPO> advancePOToPaid(FinancedPO po, Optional<MurabahaState> murabahaState) {
if ("murabaha".equals(paymentPath(po))) {
// => Murabaha path: contract must be SETTLED before PO can close
boolean settled = murabahaState.map(s -> s == MurabahaState.SETTLED).orElse(false);
if (!settled) {
String contractState = murabahaState.map(Enum::name).orElse("unknown");
return new Result.Err<>("Murabaha-financed PO cannot be marked Paid: contract is " + contractState);
// => MurabahaContract must complete all installments before PO closes
}
}
// => Both paths reach here: transition PO to Paid state
return new Result.Ok<>(new FinancedPO(po.id(), po.totalAmount(), "Paid", po.murabahaContractId()));
}
// Demo
var murabahaPO = new FinancedPO("po_mur_01", 30000.0, "Invoiced", Optional.of("mur_003"));
// => This PO is linked to a MurabahaContract — Sharia-financed procurement
var r1 = advancePOToPaid(murabahaPO, Optional.of(MurabahaState.INSTALLMENT_PENDING));
if (r1 instanceof Result.Err<FinancedPO> err)
System.out.println(err.error());
// => Output: Murabaha-financed PO cannot be marked Paid: contract is INSTALLMENT_PENDING
var r2 = advancePOToPaid(murabahaPO, Optional.of(MurabahaState.SETTLED));
if (r2 instanceof Result.Ok<FinancedPO> ok)
System.out.println(ok.value().state()); // => Output: Paid
// => Conventional PO: no murabaha contract — guard bypassed entirely
var conventionalPO = new FinancedPO("po_conv_01", 15000.0, "Invoiced", Optional.empty());
var r3 = advancePOToPaid(conventionalPO, Optional.empty());
if (r3 instanceof Result.Ok<FinancedPO> ok)
System.out.println(ok.value().state()); // => Output: Paid (no murabaha guard applies)Key Takeaway: The optional murabaha path adds a guard on the PO's Paid transition without changing the rest of the PO machine — optional features should extend the machine with guards, not restructure it.
Why It Matters: If murabaha financing were a core assumption in the PO machine, the machine would be unusable without it. By making it optional — a guard that fires only when murabahaContractId is present — the PO machine works for both conventional and Sharia-financed procurement without duplication. This is the Open-Closed principle applied to state machines: open for extension, closed for modification.
Production Patterns (Examples 68-75)
Example 68: Actor Model — FSM as an Actor
In the Akka or Erlang actor model, each aggregate instance is an actor. The actor receives events, updates its FSM state, and persists the result. This patterns pairs naturally with FSM because actors are inherently single-threaded — no locking required.
import java.util.function.Consumer;
// => PaymentActor: encapsulates a single Payment's FSM state — simulates Akka actor
class PaymentActor {
private PaymentWithRetries state;
// => Actor's internal FSM state: mutable only via receive() — the sole message handler
// => No shared state: each actor owns exactly one Payment — no race conditions
public PaymentActor(PaymentWithRetries initialPayment) {
this.state = initialPayment;
// => Each actor is instantiated with one payment in its starting state
}
// => receive: process a single event — simulates Akka's behavior(receive(...))
public void receive(PaymentEvent event, Consumer<Result<Payment>> replyTo) {
// => Single-threaded: no locking needed; actor dispatches one message per cycle
var r = PaymentFSM.apply(state, event);
// => Apply FSM transition; PaymentFSM.apply is the pure transition function
if (r instanceof Result.Ok<Payment> ok) {
state = new PaymentWithRetries(
ok.value().id(), ok.value().invoiceId(),
ok.value().amount(), ok.value().bankAccount(),
ok.value().state(), state.retryCount(), state.maxRetries()
);
// => Update only state field; preserve retry tracking fields
}
replyTo.accept(r);
// => Deliver result to caller; in real Akka: sender().tell(r, getSelf())
}
public PaymentState currentState() {
return state.state();
// => Read-only access to current FSM state — no mutation possible externally
}
}
// Demo: actor processes a sequence of events
var actor = new PaymentActor(new PaymentWithRetries(
"pay_actor_01", "inv_011", 6000.0, "GB29NWBK60161331926821",
PaymentState.SCHEDULED, 0, 3
));
// => Start: actor owns one payment in SCHEDULED state, 0 retries used
actor.receive(PaymentEvent.DISBURSE, r -> {
if (r instanceof Result.Ok<Payment> ok)
System.out.println(ok.value().state()); // => Output: DISBURSED
});
actor.receive(PaymentEvent.REMIT, r -> {
if (r instanceof Result.Ok<Payment> ok)
System.out.println(ok.value().state()); // => Output: REMITTED
});
actor.receive(PaymentEvent.FAIL, r -> {
// => REMITTED is terminal: FSM rejects the event — no transition exists
if (r instanceof Result.Err<Payment> err)
System.out.println(err.error()); // => Output: REMITTED --FAIL--> (forbidden)
});Key Takeaway: The actor model and the FSM model align naturally — each actor owns exactly one aggregate's state, processes events one at a time, and is its own lock-free consistency boundary.
Why It Matters: In a high-throughput payment system, thousands of payments may be processing simultaneously. The actor model scales by giving each payment its own isolated execution context — no global mutex, no serialisation bottleneck. The FSM provides the safety guarantees within each actor; the actor model provides the concurrency model across actors.
Example 69: Optimistic Concurrency — Version Numbers
When two processes try to update the same PO simultaneously, optimistic concurrency prevents the second update from overwriting the first.
import java.util.function.Function;
// => Versioned<T>: wraps any record with a version counter for optimistic locking
record Versioned<T>(T data, int version) {
// => version: incremented on each successful update
// => Two callers loading the same version will race; the second one loses
}
// => updateVersioned: fails if the stored version has changed since the caller loaded it
static <T> Result<Versioned<T>> updateVersioned(
Versioned<T> stored,
Versioned<T> expected,
Function<T, Result<T>> updater) {
if (stored.version() != expected.version()) {
return new Result.Err<>(
"Concurrency conflict: expected version " + expected.version() +
", found " + stored.version());
// => Version mismatch: another process updated this record between load and save
}
var r = updater.apply(stored.data());
// => Run the FSM transition on the current data
if (r instanceof Result.Err<T> err) {
return new Result.Err<>(err.error());
// => FSM transition failed: propagate the error
}
return new Result.Ok<>(new Versioned<>(((Result.Ok<T>) r).value(), stored.version() + 1));
// => Increment version on success: next caller must use this new version number
}
// => Simple PO record for the concurrency demonstration
record PO(String id, double totalAmount, String state) {}
var storedPO = new Versioned<>(new PO("po_conc_01", 500.0, "AwaitingApproval"), 1);
// => Version 1: both approvers load this before either saves
var approver2 = storedPO;
// => Approver 2 loads at version 1 — race condition simulated here
// => Approver 1 succeeds; store is now at version 2
var storedAfterFirst = new Versioned<>(new PO("po_conc_01", 500.0, "Approved"), 2);
// => In production: first approver's save increments the database row version to 2
// => Approver 2 tries to save: version 1 expected, but store has version 2
var r2 = updateVersioned(
storedAfterFirst, // => Current store state (version 2)
approver2, // => What approver 2 expects (version 1)
po -> new Result.Ok<>(new PO(po.id(), po.totalAmount(), "Approved"))
);
if (r2 instanceof Result.Err<Versioned<PO>> err)
System.out.println(err.error());
// => Output: Concurrency conflict: expected version 1, found 2
// => Resolution: reload from store at version 2 and retry
var reloaded = new Versioned<>(storedAfterFirst.data(), 2);
var r3 = updateVersioned(storedAfterFirst, reloaded,
po -> new Result.Ok<>(new PO(po.id(), po.totalAmount(), "Approved")));
if (r3 instanceof Result.Ok<Versioned<PO>> ok)
System.out.println("Resolved at version " + ok.value().version());
// => Output: Resolved at version 3Key Takeaway: Optimistic concurrency with version numbers prevents double-apply of the same event — the second concurrent transition is detected and rejected before the FSM even runs.
Why It Matters: In a distributed system, two HTTP requests might hit two different pods, both loading the same PO at version 1 and both attempting to approve it. Without optimistic locking, both succeed and the PO is approved twice — creating duplicate Approved state change records and potentially triggering double notifications. Version numbers make the second attempt a detected conflict, not a silent duplicate.
Example 70: Saga Pattern — Coordinating PO + Invoice + Payment
A saga coordinates the three FSMs across a long-running process. If any step fails, the saga executes compensating transactions to undo previous steps.
stateDiagram-v2
[*] --> SagaStarted
SagaStarted --> POIssued: issue PO
POIssued --> InvoiceMatched: match invoice
InvoiceMatched --> PaymentDisbursed: disburse payment
PaymentDisbursed --> SagaCompleted: all done
POIssued --> Compensating: PO issue fails
InvoiceMatched --> Compensating: match fails
PaymentDisbursed --> Compensating: payment fails
Compensating --> SagaFailed: compensations done
classDef started fill:#0173B2,stroke:#000,color:#fff
classDef active fill:#029E73,stroke:#000,color:#fff
classDef compensating fill:#CC78BC,stroke:#000,color:#fff
classDef terminal fill:#CA9161,stroke:#000,color:#fff
class SagaStarted started
class POIssued,InvoiceMatched,PaymentDisbursed active
class Compensating compensating
class SagaCompleted active
class SagaFailed terminal
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.function.Supplier;
// => SagaStep<T>: a named forward action paired with its compensating rollback
record SagaStep<T>(
String name, // => Human-readable step name for logging
Supplier<Result<T>> execute, // => Forward action: attempt the business operation
Runnable compensate // => Compensating action: undo this step's side effects
) {}
// => runSaga: execute steps in order; compensate in reverse on any failure
@SafeVarargs
static <T> Result<List<T>> runSaga(SagaStep<T>... steps) {
List<T> results = new ArrayList<>();
List<SagaStep<T>> executed = new ArrayList<>();
// => executed: tracks steps to reverse on failure; only successful steps entered
for (var step : steps) {
var r = step.execute().get();
// => Attempt the forward action for this saga step
if (r instanceof Result.Err<T> err) {
// => Step failed: compensate all previously executed steps in reverse order
List<SagaStep<T>> toCompensate = new ArrayList<>(executed);
Collections.reverse(toCompensate);
for (var s : toCompensate) {
System.out.println("Compensating: " + s.name());
s.compensate().run();
// => Rollback: undo this step's side effects
}
return new Result.Err<>("Saga failed at step '" + step.name() + "': " + err.error());
}
results.add(((Result.Ok<T>) r).value());
executed.add(step);
System.out.println("Step '" + step.name() + "' succeeded");
// => Step succeeded: record result and mark as executed for potential rollback
}
return new Result.Ok<>(results);
// => All steps succeeded: return collected results
}
// => P2P saga: PO issue -> Payment disburse (two-step example)
var po = new PO("po_saga_01", 5000.0, "Approved");
var pmt = new Payment("pay_saga_01", "inv_saga_01", 5000.0,
"GB29NWBK60161331926822", PaymentState.SCHEDULED);
var sagaResult = runSaga(
new SagaStep<>(
"IssuePO",
() -> new Result.Ok<>(new PO(po.id(), po.totalAmount(), "Issued")),
// => Compensate: revert PO from Issued back to Approved
() -> System.out.println(" Cancel PO issue: PO reverted to Approved")
),
new SagaStep<>(
"DisbursePayment",
() -> PaymentFSM.apply(pmt, PaymentEvent.DISBURSE),
// => Compensate: reverse payment disbursement (bank recall)
() -> System.out.println(" Reverse payment disbursement")
)
);
if (sagaResult instanceof Result.Ok<List<Object>> ok)
System.out.println("Saga complete: " + ok.value().size() + " steps succeeded");
else if (sagaResult instanceof Result.Err<List<Object>> err)
System.out.println("Saga failed: " + err.error());
// => Output: Step 'IssuePO' succeeded
// => Output: Step 'DisbursePayment' succeeded
// => Output: Saga complete: 2 steps succeededKey Takeaway: Sagas replace distributed transactions — each step can succeed or fail independently, and compensation rolls back exactly the steps that succeeded.
Why It Matters: Distributed transactions (two-phase commit) are unavailable across microservices. Sagas provide eventual consistency: if the payment disbursement fails after the PO is issued, the compensation cancels the PO issuance. The FSM for each aggregate enforces that compensating events (like cancel) are valid in the post-failure state — the two patterns compose naturally.
Example 71: State Machine Snapshot and Resume
In long-running workflows, the machine might be interrupted (process crash, pod restart). A snapshot captures all FSM state at a checkpoint, enabling resume without replaying the full event history.
import java.time.Instant;
import java.util.Optional;
import java.util.UUID;
// => P2PSnapshot: point-in-time capture of all three P2P aggregate states
record P2PSnapshot(
String snapshotId, // => Unique ID for this snapshot: snap_<uuid>
String takenAt, // => ISO 8601 timestamp when snapshot was taken
PO po, // => PO aggregate state at snapshot time
Optional<Invoice> invoice, // => Invoice state; empty if invoice not yet created
Optional<Payment> payment, // => Payment state; empty if payment not yet created
int eventsAfter // => Count of events that produced this state
// => eventsAfter is the index to start replaying from: only events[eventsAfter..] need replay
) {}
// => takeSnapshot: pure function — captures current state without side effects
static P2PSnapshot takeSnapshot(
PO po,
Optional<Invoice> invoice,
Optional<Payment> payment,
int eventCount) {
return new P2PSnapshot(
"snap_" + UUID.randomUUID(), // => Unique snapshot id; UUID prevents collisions
Instant.now().toString(), // => ISO 8601 timestamp for audit trail
po,
invoice,
payment,
eventCount
// => eventCount tells the replay engine where to start after loading this snapshot
);
}
// => resumeFromSnapshot: extract the three aggregate states for use as FSM starting points
static record ResumedState(PO po, Optional<Invoice> invoice, Optional<Payment> payment) {}
static ResumedState resumeFromSnapshot(P2PSnapshot snap) {
return new ResumedState(snap.po(), snap.invoice(), snap.payment());
// => No replay needed: snapshot IS the starting point
// => Events with index > snap.eventsAfter() are replayed on top of these restored states
}
// Demo
var po = new PO("po_snap", 8000.0, "Acknowledged");
var invoice = new Invoice("inv_snap", "po_snap", 8000.0, "Matching");
var snap = takeSnapshot(po, Optional.of(invoice), Optional.empty(), 42);
// => 42 events produced this state; Payment not yet created
System.out.println(snap.snapshotId().startsWith("snap_")); // => Output: true
System.out.println(snap.eventsAfter()); // => Output: 42
var resumed = resumeFromSnapshot(snap);
System.out.println(resumed.po().state()); // => Output: Acknowledged
System.out.println(resumed.invoice().map(Invoice::state).orElse("none")); // => Output: Matching
System.out.println(resumed.payment().isPresent()); // => Output: false (not yet created)Key Takeaway: Snapshots + partial event replay combine the correctness of event sourcing with the performance of state snapshots — no need to replay years of events on every process restart.
Why It Matters: A procurement system with 5 years of history might have millions of events per PO. Replaying all of them on each service restart would take minutes. Snapshots reduce the replay window to events since the last checkpoint — typically a few hours of data. The FSM replay function (rebuildPayment from Example 62) is then applied to only the incremental events.
Example 72: FSM Visualisation — Mermaid from Code
Generate a Mermaid state diagram directly from the transition table so the diagram always matches the implementation.
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
// => generateMermaid: pure function — derives a Mermaid stateDiagram-v2 from a
// => transition table represented as Map<String, Map<String, String>>.
// => Title, table, and terminal list are the only inputs; no I/O side effects.
static String generateMermaid(
String title,
Map<String, Map<String, String>> table,
List<String> terminalStates) {
List<String> lines = new ArrayList<>();
// => Start with the Mermaid fence and directive — printed verbatim
lines.add("```mermaid");
lines.add("stateDiagram-v2");
lines.add(" %% " + title);
// => %% comment carries the machine name for reader orientation
// => Initial state: first key in the ordered map is the entry state
String initialState = table.keySet().iterator().next();
// => LinkedHashMap preserves insertion order, so first key == starting state
lines.add(" [*] --> " + initialState);
// => [*] is Mermaid's pseudo-state representing the history origin
// => Transitions: iterate outer map (from-state) then inner map (event → to-state)
for (Map.Entry<String, Map<String, String>> fromEntry : table.entrySet()) {
String from = fromEntry.getKey();
// => from: the source state that has outgoing transitions
for (Map.Entry<String, String> eventEntry : fromEntry.getValue().entrySet()) {
String event = eventEntry.getKey();
String to = eventEntry.getValue();
// => event: the trigger; to: the target state
lines.add(" " + from + " --> " + to + ": " + event);
// => Format: " SOURCE --> TARGET: TRIGGER" — standard stateDiagram-v2 syntax
}
}
// => Terminal states: each gets a "[*]" exit arrow (no outgoing transitions in table)
for (String terminal : terminalStates) {
lines.add(" " + terminal + " --> [*]");
// => Terminal → [*] signals the state machine reaches a final state
}
lines.add("```");
// => Close the Mermaid fence so the rendered output is a valid code block
return String.join("\n", lines);
// => Join with newlines to produce a single printable string
}
// => Payment transition table — LinkedHashMap preserves insertion order
var paymentTransitions = new java.util.LinkedHashMap<String, Map<String, String>>();
paymentTransitions.put("Scheduled", Map.of(
"disburse", "Disbursed",
"reverse", "Reversed"));
paymentTransitions.put("Disbursed", Map.of(
"remit", "Remitted",
"fail", "Failed"));
paymentTransitions.put("Failed", Map.of(
"retry", "Scheduled",
"reverse", "Reversed"));
// => Three source states; Remitted and Reversed have no outgoing transitions
String diagram = generateMermaid(
"Payment FSM",
paymentTransitions,
List.of("Remitted", "Reversed"));
// => Generates the complete Mermaid block from the data above
// => Print first 8 lines to verify the generated output
diagram.lines().limit(8).forEach(System.out::println);
// => Output:
// => ```mermaid
// => stateDiagram-v2
// => %% Payment FSM
// => [*] --> Scheduled
// => Scheduled --> Disbursed: disburse
// => Scheduled --> Reversed: reverse
// => Disbursed --> Remitted: remit
// => Disbursed --> Failed: failKey Takeaway: Diagram generation from the transition table ensures documentation is never out of sync with the implementation — the code is the single source of truth.
Why It Matters: Manually drawn state diagrams become lies within weeks of the first post-launch bug fix. Generated diagrams from the transition table are always accurate — when a developer adds the partial_receive transition, the diagram automatically shows it. In a PR, reviewers see both the code change and the updated diagram as a single diff.
Example 73: FSM-Driven API Response Codes
The FSM state determines which HTTP status codes the API returns — a structural mapping that prevents ad-hoc HTTP status decisions in controller code.
import java.util.Map;
import java.util.Optional;
// => FsmApiResponse: value object representing an HTTP response derived from an FSM result.
// => Record is appropriate — immutable, no behaviour, equality by value.
record FsmApiResponse(int status, Object body) {}
// => PurchaseOrder and Result types (abbreviated for the example)
enum PoState { DRAFT, AWAITING_APPROVAL, APPROVED, CLOSED }
record PurchaseOrder(String id, int totalAmount, PoState state) {}
// => Result<T>: sealed hierarchy — Ok carries the value, Err carries the message
sealed interface Result<T> permits Result.Ok, Result.Err {
record Ok<T>(T value) implements Result<T> {}
record Err<T>(String error) implements Result<T> {}
}
// => PO transition table: state → (event → nextState)
static final Map<PoState, Map<String, PoState>> PO_TRANSITIONS = Map.of(
PoState.DRAFT, Map.of("submit", PoState.AWAITING_APPROVAL),
PoState.AWAITING_APPROVAL, Map.of("approve", PoState.APPROVED),
PoState.APPROVED, Map.of("close", PoState.CLOSED)
);
// => applyEvent: pure FSM — returns Ok(nextState) or Err(reason), no side effects
static Result<PurchaseOrder> applyEvent(PurchaseOrder po, String event) {
Map<String, PoState> transitions = PO_TRANSITIONS.get(po.state());
// => Look up outgoing transitions for the current state
if (transitions == null || !transitions.containsKey(event)) {
return new Result.Err<>(po.state() + " --" + event + "--> (forbidden)");
// => No transition found: event is forbidden in this state
}
PoState next = transitions.get(event);
return new Result.Ok<>(new PurchaseOrder(po.id(), po.totalAmount(), next));
// => Transition found: return updated PO with new state
}
// => toHttpResponse: maps FSM Result to an HTTP response — no ad-hoc status decisions
static FsmApiResponse toHttpResponse(Result<PurchaseOrder> result, String event) {
if (result instanceof Result.Ok<PurchaseOrder> ok) {
// => Success path: 200 OK with the new state
return new FsmApiResponse(200,
Map.of("id", ok.value().id(), "state", ok.value().state().name()));
// => 200 OK: transition succeeded, body carries new state for client optimistic update
}
String error = ((Result.Err<PurchaseOrder>) result).error();
// => Classify the error: forbidden transition → 409, business guard failure → 422
boolean isConflict = error.contains("(forbidden)") || error.startsWith("Cannot");
// => (forbidden): FSM had no transition; Cannot: guard blocked it — both are state conflicts
if (isConflict) {
return new FsmApiResponse(409, Map.of(
"error", "Transition not allowed in current state",
"detail", error,
"hint", "Event '" + event + "' is not valid for the current PO state"));
// => 409 Conflict: the request is valid HTTP but conflicts with domain state
// => Clients should retry after the state changes (not fix the request)
}
return new FsmApiResponse(422, Map.of(
"error", "Business rule violation",
"detail", error));
// => 422 Unprocessable: request data is wrong (e.g., insufficient approval level)
// => Client must fix request data — retrying unchanged will always fail
}
// => Simulate controller calls
PurchaseOrder po = new PurchaseOrder("po_http_01", 500, PoState.DRAFT);
Result<PurchaseOrder> r1 = applyEvent(po, "submit");
// => submit from DRAFT → AWAITING_APPROVAL: valid transition
System.out.println(toHttpResponse(r1, "submit").status());
// => Output: 200
Result<PurchaseOrder> r2 = applyEvent(po, "close");
// => close from DRAFT → (forbidden): no transition defined
System.out.println(toHttpResponse(r2, "close").status());
// => Output: 409
@SuppressWarnings("unchecked")
var body = (Map<String, Object>) toHttpResponse(r2, "close").body();
System.out.println(body.get("error"));
// => Output: Transition not allowed in current stateKey Takeaway: Mapping FSM results to HTTP status codes structurally — 200 for success, 409 for forbidden transitions, 422 for business guard failures — produces consistent API semantics without ad-hoc status decisions.
Why It Matters: APIs that return 400 for every error force clients to parse error message text to understand the nature of the failure. Distinguishing 409 (state conflict) from 422 (business validation) lets clients handle each case appropriately: 409 means "retry after the state changes", 422 means "fix your request data". This distinction is the difference between a usable API and a frustrating one.
Example 74: Testing All Four Machines Together
An integration-style test that walks a complete P2P workflow: PO from Draft to Closed, Invoice from Registered to Paid, Supplier approval, Payment from Scheduled to Remitted.
import java.util.List;
import java.util.Map;
// => Abbreviated domain types — each is an immutable record with an enum state
enum SupplierState { PENDING, APPROVED, SUSPENDED, BLACKLISTED }
enum PoState { DRAFT, AWAITING_APPROVAL, APPROVED, ISSUED, ACKNOWLEDGED, CLOSED }
enum InvoiceState { REGISTERED, MATCHING, MATCHED, SCHEDULED_FOR_PAYMENT, PAID }
enum PaymentState { SCHEDULED, DISBURSED, REMITTED, FAILED, REVERSED }
record Supplier(String id, String name, SupplierState state) {}
record PO(String id, int totalAmount, PoState state) {}
record Invoice(String id, String poId, int supplierAmount, InvoiceState state) {}
record Payment(String id, String invoiceId, int amount, String bankAccount, PaymentState state) {}
// => Transition tables — one per aggregate, keyed by current state then event string
static final Map<SupplierState, Map<String, SupplierState>> SUPPLIER_T = Map.of(
SupplierState.PENDING, Map.of("approve", SupplierState.APPROVED));
static final Map<PoState, Map<String, PoState>> PO_T = Map.of(
PoState.DRAFT, Map.of("submit", PoState.AWAITING_APPROVAL),
PoState.AWAITING_APPROVAL, Map.of("approve", PoState.APPROVED),
PoState.APPROVED, Map.of("issue", PoState.ISSUED),
PoState.ISSUED, Map.of("acknowledge", PoState.ACKNOWLEDGED),
PoState.ACKNOWLEDGED, Map.of("close", PoState.CLOSED));
static final Map<InvoiceState, Map<String, InvoiceState>> INV_T = Map.of(
InvoiceState.REGISTERED, Map.of("start_match", InvoiceState.MATCHING),
InvoiceState.MATCHING, Map.of("match_ok", InvoiceState.MATCHED),
InvoiceState.MATCHED, Map.of("schedule", InvoiceState.SCHEDULED_FOR_PAYMENT),
InvoiceState.SCHEDULED_FOR_PAYMENT, Map.of("pay", InvoiceState.PAID));
static final Map<PaymentState, Map<String, PaymentState>> PMT_T = Map.of(
PaymentState.SCHEDULED, Map.of("disburse", PaymentState.DISBURSED),
PaymentState.DISBURSED, Map.of("remit", PaymentState.REMITTED));
// => Generic apply helper — returns new record with updated state, or throws on forbidden
static <S extends Enum<S>, R> R applyStep(
Map<S, Map<String, S>> table, S current, String event,
java.util.function.Function<S, R> wrap) {
var next = table.getOrDefault(current, Map.of()).get(event);
// => getOrDefault: safe map lookup — returns empty map if state has no transitions
if (next == null) throw new AssertionError(current + " --" + event + "--> (forbidden)");
return wrap.apply(next);
// => wrap: caller supplies a lambda that builds the updated record
}
// => testFullP2PHappyPath: drives all four machines through the happy path
static void testFullP2PHappyPath() {
// --- Supplier ---
Supplier sup = new Supplier("sup_it_01", "Integration Supplies", SupplierState.PENDING);
sup = applyStep(SUPPLIER_T, sup.state(), "approve",
s -> new Supplier(sup.id(), sup.name(), s));
// => approve from PENDING → APPROVED: supplier is now eligible for PO selection
assert sup.state() == SupplierState.APPROVED;
System.out.println("Supplier: " + sup.state());
// => Output: Supplier: APPROVED
// --- PO lifecycle ---
PO po = new PO("po_it_01", 3000, PoState.DRAFT);
for (var step : List.of(
Map.entry("submit", PoState.AWAITING_APPROVAL),
Map.entry("approve", PoState.APPROVED),
Map.entry("issue", PoState.ISSUED),
Map.entry("acknowledge", PoState.ACKNOWLEDGED))) {
po = applyStep(PO_T, po.state(), step.getKey(),
s -> new PO(po.id(), po.totalAmount(), s));
assert po.state() == step.getValue() : "PO expected " + step.getValue();
System.out.println("PO: " + po.state());
// => Output (4 lines): AWAITING_APPROVAL, APPROVED, ISSUED, ACKNOWLEDGED
}
// --- Invoice ---
Invoice inv = new Invoice("inv_it_01", "po_it_01", 3045, InvoiceState.REGISTERED);
for (var event : List.of("start_match", "match_ok", "schedule", "pay")) {
inv = applyStep(INV_T, inv.state(), event,
s -> new Invoice(inv.id(), inv.poId(), inv.supplierAmount(), s));
System.out.println("Invoice: " + inv.state());
// => Output (4 lines): MATCHING, MATCHED, SCHEDULED_FOR_PAYMENT, PAID
}
// => match_ok passes because 3045/3000 = 1.5% delta < 2% tolerance guard
// --- Payment ---
Payment pmt = new Payment("pay_it_01", "inv_it_01", 3045, "GB29NWBK60161331926823",
PaymentState.SCHEDULED);
for (var event : List.of("disburse", "remit")) {
pmt = applyStep(PMT_T, pmt.state(), event,
s -> new Payment(pmt.id(), pmt.invoiceId(), pmt.amount(), pmt.bankAccount(), s));
System.out.println("Payment: " + pmt.state());
// => Output (2 lines): DISBURSED, REMITTED
}
System.out.println("PASS: Full P2P happy path complete");
// => All four machines reached their expected states: the domain model is consistent
}
testFullP2PHappyPath();
// => Output sequence: Supplier: APPROVED / PO: AWAITING_APPROVAL ... ACKNOWLEDGED
// => Invoice: MATCHING ... PAID / Payment: DISBURSED, REMITTED / PASSKey Takeaway: An integration test that exercises all four machines in sequence validates the domain model as a whole — not just individual transitions, but the complete protocol.
Why It Matters: Unit tests verify individual transitions; integration tests verify that the machines fit together. The full P2P happy path is the key acceptance criterion for the domain model — if it runs end-to-end without errors, the four FSMs are consistent with the domain specification. When the spec changes (a new state, a new transition), this test fails first — pointing directly to the gap.
Example 75: Statechart Summary — All Four Machines
A final synthesis example showing all four P2P state machines as a unified statechart specification.
import java.util.List;
import java.util.Map;
// => MachineSpec: value object capturing the summary metrics for one FSM
// => Record is ideal — immutable, equality by value, no behaviour needed here
record MachineSpec(
String name, // => Machine name for display
int states, // => Total state count (including terminal states)
int events, // => Total event/trigger count
int terminal, // => Number of terminal states (no outgoing transitions)
String role, // => One-line description of this machine's domain role
List<String> coordinates // => Names of machines this one reads state from
) {}
// => P2pStatechart: top-level specification record — all four machines + cross-cutting concerns
record P2pStatechart(
List<MachineSpec> machines, // => Ordered list: dependency order (Supplier first)
String eventBus, // => How machines coordinate across bounded context boundaries
List<String> patterns // => Advanced FSM patterns demonstrated in this tutorial
) {}
// => Build the complete P2P statechart specification
var spec = new P2pStatechart(
List.of(
new MachineSpec("Supplier", 4, 4, 1,
"Vendor lifecycle — gates PO eligibility",
List.of()),
// => No upstream dependency: Supplier is the root of the P2P dependency graph
new MachineSpec("PurchaseOrder", 12, 10, 2,
"Core procurement lifecycle — the workflow spine",
List.of("Supplier")),
// => Reads Supplier state: eligibility guard blocks PO submission to blacklisted suppliers
new MachineSpec("Invoice", 6, 6, 1,
"Financial validation — three-way match enforcement",
List.of("PurchaseOrder")),
// => InvoiceMatched event triggers PO.invoice_matched — cross-machine coordination
new MachineSpec("Payment", 5, 5, 2,
"Disbursement tracking — bank transfer lifecycle",
List.of("Invoice"))
// => Payment created only after Invoice reaches ScheduledForPayment
),
"Domain events coordinate machines across bounded context boundaries",
// => No direct machine-to-machine calls: loose coupling via published domain events
List.of(
"Hierarchical states (Supplier tiers, Payment parallel regions)",
"History states (Supplier reinstatement to previous tier)",
"Parallel regions (Payment transfer + notification)",
"Event sourcing (replay from event log)",
"Saga (compensating transactions across machines)",
"Optimistic concurrency (version numbers)",
"Snapshot + partial replay (performance at scale)"
)
);
// => Print summary — total states across all machines
System.out.println("P2P FSM System Summary");
System.out.println("======================");
int totalStates = 0;
for (var m : spec.machines()) {
totalStates += m.states();
System.out.printf("%s: %d states, %d events — %s%n",
m.name(), m.states(), m.events(), m.role());
// => One line per machine: state count, event count, role description
}
System.out.println("Total states across all machines: " + totalStates);
// => Output:
// => Supplier: 4 states, 4 events — Vendor lifecycle — gates PO eligibility
// => PurchaseOrder: 12 states, 10 events — Core procurement lifecycle — the workflow spine
// => Invoice: 6 states, 6 events — Financial validation — three-way match enforcement
// => Payment: 5 states, 5 events — Disbursement tracking — bank transfer lifecycle
// => Total states across all machines: 27
System.out.println("\nPatterns covered: " + spec.patterns().size());
// => Output: Patterns covered: 7
// => Seven patterns: the breadth of real-world FSM techniques demonstrated in this tutorialKey Takeaway: The four P2P state machines together define the complete procurement protocol — 27 states, coordinated by domain events, enforcing business rules structurally rather than through ad-hoc conditional logic.
Why It Matters: This is the value proposition of FSM-driven domain modelling: the entire P2P protocol — supplier vetting, purchase approval, three-way match, payment disbursement — is expressed as explicit states, explicit transitions, and explicit guards. There are no hidden states, no undocumented transitions, and no business rules buried in controller code. A regulator asking how the system prevents payment to a blacklisted supplier gets a structural answer: the Supplier FSM makes it impossible, and the Payment FSM cannot be created without a matched invoice linked to an issued PO. The FSMs are the compliance artefact.
Last updated January 30, 2026