Intermediate
This intermediate tutorial adds the Invoice state machine from the procurement-platform-be domain alongside the PurchaseOrder machine introduced in the beginner level. You will learn how to enforce guards that depend on external invariants (the three-way match), how state-machine libraries model entry/exit actions declaratively, and how the FSM functions as a protocol enforcement layer that rejects out-of-order business events.
The Invoice State Machine (Examples 26-31)
Example 26: Invoice States and the Three-Way Match
An Invoice from a supplier goes through matching before payment is scheduled. The match rule — invoice amount must equal sum(GRN quantities × PO unit price) within a 2% tolerance — is the central guard of this machine.
stateDiagram-v2
[*] --> Registered
Registered --> Matching: start_match
Matching --> Matched: match_ok
Matching --> Disputed: match_fail
Disputed --> Matching: resubmit
Matched --> ScheduledForPayment: schedule
ScheduledForPayment --> Paid: pay
Paid --> [*]
classDef registered fill:#0173B2,stroke:#000,color:#fff
classDef matching fill:#DE8F05,stroke:#000,color:#000
classDef matched fill:#029E73,stroke:#000,color:#fff
classDef disputed fill:#CC78BC,stroke:#000,color:#fff
classDef terminal fill:#CA9161,stroke:#000,color:#fff
class Registered registered
class Matching matching
class Matched matched
class Disputed disputed
class ScheduledForPayment,Paid terminal
import java.util.EnumMap;
import java.util.Map;
// => Invoice FSM: models the lifecycle of a supplier invoice in procurement
// => Java enum encodes every valid invoice state as a named constant
public enum InvoiceState {
REGISTERED, // => Supplier submitted invoice; not yet matched
MATCHING, // => Three-way match in progress
MATCHED, // => Match passed within tolerance; ready for payment
DISPUTED, // => Match failed; supplier must correct and resubmit
SCHEDULED_FOR_PAYMENT, // => Finance scheduled the payment run
PAID // => Bank disbursement confirmed — terminal state
}
// => Java record: immutable Invoice value object (Java 16+)
// => Each state transition returns a new Invoice — no in-place mutation
public record Invoice(
String id, // => Format: inv_<uuid>; uniquely identifies the invoice
String poId, // => Links invoice to a PurchaseOrder aggregate
double supplierAmount, // => Amount the supplier claims (USD)
InvoiceState state // => Current FSM state — drives all lifecycle decisions
) {}
// => Transition table as a nested EnumMap for type-safe, O(1) lookups
// => EnumMap is more efficient than HashMap for enum keys; static block initialises once
public static final Map<InvoiceState, Map<String, InvoiceState>> INVOICE_TRANSITIONS;
static {
INVOICE_TRANSITIONS = new EnumMap<>(InvoiceState.class);
// => Registered: only valid event is start_match — begins three-way matching
INVOICE_TRANSITIONS.put(InvoiceState.REGISTERED,
Map.of("start_match", InvoiceState.MATCHING));
// => Matching: two outcomes determined by guard — match_ok or match_fail
INVOICE_TRANSITIONS.put(InvoiceState.MATCHING,
Map.of("match_ok", InvoiceState.MATCHED,
"match_fail", InvoiceState.DISPUTED));
// => Disputed: supplier resubmits corrected invoice — returns to Matching
INVOICE_TRANSITIONS.put(InvoiceState.DISPUTED,
Map.of("resubmit", InvoiceState.MATCHING));
// => Matched: finance approves for upcoming payment run
INVOICE_TRANSITIONS.put(InvoiceState.MATCHED,
Map.of("schedule", InvoiceState.SCHEDULED_FOR_PAYMENT));
// => ScheduledForPayment: bank confirms disbursement → terminal PAID
INVOICE_TRANSITIONS.put(InvoiceState.SCHEDULED_FOR_PAYMENT,
Map.of("pay", InvoiceState.PAID));
// => PAID has no entry — terminal state, no valid outgoing events
}Key Takeaway: The Invoice FSM mirrors the PO FSM in structure — sealed states, event alphabet, transition table — but its guards depend on external data (GRN, PO unit prices) that are passed in at match time.
Why It Matters: The three-way match (PO ↔ GRN ↔ Invoice) is the central fraud-prevention control in procurement. Encoding it as an FSM guard means the system structurally cannot mark an invoice as Matched unless the match computation actually passed — the state change and the validation are inseparable.
Example 27: The Three-Way Match Guard
The match_ok event is only valid if the invoice amount falls within tolerance of the expected amount derived from the GRN and PO unit prices.
stateDiagram-v2
[*] --> Matching
Matching --> Matched: match_ok [within tolerance]
Matching --> Disputed: match_fail [outside tolerance]
note right of Matching
Three-way match:
Invoice ≈ GRN × PO price
(within 2% tolerance)
end note
Disputed --> Matching: resubmit
classDef matching fill:#DE8F05,stroke:#000,color:#000
classDef matched fill:#029E73,stroke:#000,color:#fff
classDef disputed fill:#CC78BC,stroke:#000,color:#fff
class Matching matching
class Matched matched
class Disputed disputed
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
// => Tolerance: the maximum allowed percentage discrepancy in the three-way match
// => Java record: immutable value object — tolerance is policy, not mutable runtime state
public record Tolerance(double percentage) {
// => percentage: 0.02 = 2%, per domain spec (max 10%)
// => Compact constructor validates the range
public Tolerance {
if (percentage < 0 || percentage > 1)
// => Guard: tolerance must be between 0% and 100%
throw new IllegalArgumentException("Tolerance must be between 0.0 and 1.0");
}
}
// => Goods Receipt Note line: represents one physical delivery line
// => receivedQty is what arrived at the warehouse — the ground truth for matching
public record GRNLine(String skuCode, int receivedQty) {}
// => skuCode must match a PO line; receivedQty is the actual quantity received
// => PO line for matching: provides the agreed unit price from the purchase order
public record POLineForMatch(String skuCode, double unitPrice) {}
// => unitPrice is the contractual reference — what was agreed at ordering time
// => Pure function: compute expected invoice amount from GRN and PO data
// => No side effects — can be tested without any state machine setup
public static double computeExpectedAmount(
List<GRNLine> grnLines, List<POLineForMatch> poLines) {
// => Build SKU → unitPrice map for O(1) per-line lookup
Map<String, Double> priceMap = poLines.stream()
.collect(Collectors.toMap(POLineForMatch::skuCode, POLineForMatch::unitPrice));
// => priceMap: { "ELC-0042" -> 50.0, ... }
return grnLines.stream()
.mapToDouble(grn -> {
double price = priceMap.getOrDefault(grn.skuCode(), 0.0);
// => 0.0 if SKU missing from PO — forces match failure for unknown SKUs
return grn.receivedQty() * price;
// => Line contribution: received quantity × agreed price
})
.sum();
// => Total expected amount = sum of all line contributions
}
// => Three-way match guard: true if invoice amount is within tolerance of expected
// => Pure function — deterministic, side-effect-free, independently testable
public static boolean threeWayMatchPasses(
Invoice invoice, List<GRNLine> grnLines,
List<POLineForMatch> poLines, Tolerance tolerance) {
double expected = computeExpectedAmount(grnLines, poLines);
// => What we expect to pay based on received goods at agreed prices
if (expected == 0) return false;
// => No goods received: cannot match — guard fails immediately
double delta = Math.abs(invoice.supplierAmount() - expected) / expected;
// => Relative discrepancy as a fraction (e.g., 0.016 = 1.6%)
return delta <= tolerance.percentage();
// => Within tolerance → match passes; outside → match fails
}
// => Example: 10 units × $50 = $500 expected; supplier invoices $508 (1.6% delta)
var grn = List.of(new GRNLine("ELC-0042", 10));
var po = List.of(new POLineForMatch("ELC-0042", 50.0));
var inv = new Invoice("inv_001", "po_001", 508.0, InvoiceState.MATCHING);
var tol = new Tolerance(0.02);
System.out.println(threeWayMatchPasses(inv, grn, po, tol));
// => Output: true (508 vs 500 = 1.6% delta ≤ 2% tolerance)
var highInv = new Invoice("inv_001", "po_001", 560.0, InvoiceState.MATCHING);
// => highInv: same PO, supplier claims $560 — 12% over expected $500
System.out.println(threeWayMatchPasses(highInv, grn, po, tol));
// => Output: false (560 vs 500 = 12% delta > 2% tolerance)Key Takeaway: The three-way match guard is a pure function that can be tested independently of the FSM — the FSM calls it as a precondition for the match_ok transition.
Why It Matters: Isolating the match computation from the state transition makes both testable without the other. You can verify that computeExpectedAmount handles currency rounding correctly without setting up an invoice state machine. You can verify the FSM rejects match_ok when the guard returns false without mocking GRN data. Composing the two gives you the full behaviour.
Example 28: Guarded Invoice Transition
Wrapping the three-way match guard in the transition function produces the complete match operation.
import java.util.List;
import java.util.Optional;
// => MatchContext: bundles all data needed for the guarded match transition
// => Java record: immutable holder; prevents partial construction of context
public record MatchContext(
Invoice invoice, // => The invoice to match — must be in MATCHING state
List<GRNLine> grnLines, // => Goods received — source of truth for quantities
List<POLineForMatch> poLines,// => PO lines — provide agreed unit prices
Tolerance tolerance // => Acceptable percentage discrepancy (e.g. 2%)
) {}
// => Result<T>: sealed interface expressing success or failure without exceptions
// => Java 17+ sealed interface: exhaustive pattern matching in switch expressions
public sealed interface Result<T> permits Result.Ok, Result.Err {
record Ok<T>(T value) implements Result<T> {}
// => Ok: transition succeeded; value is the new Invoice
record Err<T>(String error) implements Result<T> {}
// => Err: structural or business guard failed; error describes what was rejected
}
// => Guarded match transition: applies the three-way match guard before transitioning
// => Pure function: returns Result — no exceptions, no mutation of input
public static Result<Invoice> applyMatch(MatchContext ctx) {
Invoice invoice = ctx.invoice();
// => FSM structural guard: must be in MATCHING state to attempt a match
if (invoice.state() != InvoiceState.MATCHING) {
return new Result.Err<>(
"Cannot match invoice in state: " + invoice.state());
// => Wrong state: caller sent the event out of order — reject with reason
}
// => Business guard: three-way match — guard chooses the target state
boolean passes = threeWayMatchPasses(
invoice, ctx.grnLines(), ctx.poLines(), ctx.tolerance());
// => Guard is deterministic: same inputs always produce same result
InvoiceState next = passes ? InvoiceState.MATCHED : InvoiceState.DISPUTED;
// => match_ok → MATCHED; match_fail → DISPUTED — caller cannot choose this
Invoice updated = new Invoice(
invoice.id(), invoice.poId(), invoice.supplierAmount(), next);
// => Immutable update: new Invoice with the guard-determined state
return new Result.Ok<>(updated);
// => Both outcomes are valid transitions; only wrong-state is an error
}
// => Note: match_ok and match_fail are computed outcomes, not caller-chosen events
// => The guard determines which transition fires — the caller only provides data
// => Demonstrate: within tolerance → MATCHED
var ctx1 = new MatchContext(
new Invoice("inv_002", "po_002", 508.0, InvoiceState.MATCHING),
List.of(new GRNLine("ELC-0042", 10)),
List.of(new POLineForMatch("ELC-0042", 50.0)),
new Tolerance(0.02)
);
Result<Invoice> r1 = applyMatch(ctx1);
if (r1 instanceof Result.Ok<Invoice> ok)
System.out.println(ok.value().state()); // => Output: MATCHED (508 within 2% of 500)
// => Demonstrate: outside tolerance → DISPUTED
var ctx2 = new MatchContext(
new Invoice("inv_002", "po_002", 600.0, InvoiceState.MATCHING),
ctx1.grnLines(), ctx1.poLines(), ctx1.tolerance()
);
// => 600 vs 500 expected = 20% delta > 2% tolerance
Result<Invoice> r2 = applyMatch(ctx2);
if (r2 instanceof Result.Ok<Invoice> ok)
System.out.println(ok.value().state()); // => Output: DISPUTED (600 is 20% over 500)Key Takeaway: Some FSM transitions are not chosen by the caller — they are determined by a guard. The caller provides input data; the guard chooses the outcome state. This removes the temptation for callers to bypass validation by choosing the "good" event directly.
Why It Matters: If match_ok and match_fail were caller-chosen events, a buggy or malicious caller could send match_ok even when the amounts differ by 50%. Making the outcome guard-determined means the FSM itself performs the validation and chooses the transition — the caller cannot cheat.
Example 29: Linking Invoice and PurchaseOrder State Machines
When an Invoice is matched, the corresponding PurchaseOrder should transition to Invoiced. This cross-machine coordination models the domain event InvoiceMatched propagating to the purchasing context.
stateDiagram-v2
state PurchaseOrderFSM {
[*] --> Acknowledged
Acknowledged --> Invoiced: invoice_matched event
Invoiced --> Closed: close
}
state InvoiceFSM {
[*] --> Matching
Matching --> Matched: match_ok
Matched --> ScheduledForPayment: schedule
}
InvoiceFSM --> PurchaseOrderFSM: InvoiceMatched domain event
classDef po fill:#0173B2,stroke:#000,color:#fff
classDef inv fill:#029E73,stroke:#000,color:#fff
class Acknowledged,Invoiced,Closed po
class Matching,Matched,ScheduledForPayment inv
import java.time.Instant;
// => Domain event emitted by the Invoice FSM when matching succeeds
// => Java record: immutable event value object — events are facts, never mutated
public record InvoiceMatchedEvent(
String invoiceId, // => Which invoice matched — correlates to invoice aggregate
String poId, // => Which PO to update — cross-context reference
Instant timestamp // => When the match completed — Instant for precision
) {
// => No "kind" field needed: Java sealed types / pattern matching identifies the type
}
// => Extended PO state enum: adds Invoiced to the beginner machine's states
// => Invoiced: PO has a matched invoice — ready for payment authorisation
public enum ExtendedPOState {
DRAFT, AWAITING_APPROVAL, APPROVED, ISSUED,
ACKNOWLEDGED, // => Goods received and confirmed — prerequisite for invoicing
INVOICED, // => Matched invoice received from invoicing context
CLOSED, CANCELLED, DISPUTED
}
// => Handle InvoiceMatchedEvent on the PurchaseOrder FSM
// => Pure function: takes current PO state, returns Result — no exceptions
public static Result<ExtendedPOState> handleInvoiceMatched(
ExtendedPOState poState, InvoiceMatchedEvent event) {
// => Guard: PO must be in ACKNOWLEDGED state to accept invoice matching
// => ACKNOWLEDGED means goods received and confirmed — prerequisite for invoicing
if (poState != ExtendedPOState.ACKNOWLEDGED) {
return new Result.Err<>(
"PO cannot accept InvoiceMatched in state " + poState +
" (expected ACKNOWLEDGED)");
// => Out-of-order event: PO either has not received goods yet or is already invoiced
}
System.out.printf("PO %s transitioning to INVOICED by invoice %s%n",
event.poId(), event.invoiceId());
// => In production: load PO aggregate, apply event, persist via repository
return new Result.Ok<>(ExtendedPOState.INVOICED);
// => Transition: ACKNOWLEDGED → INVOICED
}
// => Demonstrate cross-machine coordination
var evt = new InvoiceMatchedEvent("inv_003", "po_003", Instant.parse("2026-01-20T10:30:00Z"));
var r1 = handleInvoiceMatched(ExtendedPOState.ACKNOWLEDGED, evt);
// => r1: Result.Ok — ACKNOWLEDGED is the valid predecessor state
if (r1 instanceof Result.Ok<ExtendedPOState> ok)
System.out.println(ok.value()); // => Output: INVOICED
var r2 = handleInvoiceMatched(ExtendedPOState.ISSUED, evt);
// => r2: Result.Err — ISSUED means goods not yet received; event is out of order
if (r2 instanceof Result.Err<ExtendedPOState> err)
System.out.println(err.error());
// => Output: PO cannot accept InvoiceMatched in state ISSUED (expected ACKNOWLEDGED)Key Takeaway: Domain events are the communication protocol between FSMs in different bounded contexts — each FSM handles only events it recognises and rejects others with a typed error.
Why It Matters: In a real system, InvoiceMatched is a Kafka message from the invoicing context consumed by the purchasing context. The PO FSM's handler validates that the PO is in the right state before applying the update, preventing out-of-order event processing from corrupting the PO lifecycle.
Example 30: Invoice FSM with Combined Tolerance Check
The Invoice FSM integrates the three-way match guard directly into the state transition, making the guard an inseparable part of the match operation. Each language expresses this combination in its own idiomatic style.
import java.util.List;
import java.util.Optional;
// => InvoiceFSM: combines the transition table with the three-way match guard
// => Java static utility class: no instances — all methods are pure functions
public final class InvoiceFSM {
private InvoiceFSM() {}
// => Private constructor: prevents instantiation of utility class
// => Transition table entry: maps event name to next state
// => Using Optional-based apply() keeps error path implicit (empty = rejected)
private static final java.util.Map<InvoiceState, java.util.Map<String, InvoiceState>> TABLE =
buildTable();
// => TABLE: lazily constructed once at class load — thread-safe via class initialisation
private static java.util.Map<InvoiceState, java.util.Map<String, InvoiceState>> buildTable() {
var t = new java.util.EnumMap<InvoiceState, java.util.Map<String, InvoiceState>>(InvoiceState.class);
t.put(InvoiceState.REGISTERED, java.util.Map.of("start_match", InvoiceState.MATCHING));
t.put(InvoiceState.MATCHING, java.util.Map.of(
"match_ok", InvoiceState.MATCHED,
"match_fail", InvoiceState.DISPUTED));
// => Matching has two outcomes; guard picks which event to send
t.put(InvoiceState.DISPUTED, java.util.Map.of("resubmit", InvoiceState.MATCHING));
t.put(InvoiceState.MATCHED, java.util.Map.of("schedule", InvoiceState.SCHEDULED_FOR_PAYMENT));
t.put(InvoiceState.SCHEDULED_FOR_PAYMENT, java.util.Map.of("pay", InvoiceState.PAID));
// => PAID: no entry — terminal state, all events silently rejected
return java.util.Collections.unmodifiableMap(t);
}
// => Pure table-driven transition: returns Optional.of(newInvoice) or Optional.empty()
// => Optional.empty() = FSM rejected the event (wrong state or unknown event)
public static Optional<Invoice> apply(Invoice inv, String event) {
return Optional.ofNullable(
TABLE.getOrDefault(inv.state(), java.util.Map.of()).get(event))
.map(next -> new Invoice(inv.id(), inv.poId(), inv.supplierAmount(), next));
// => map(): wraps state change in new Invoice record — functional style
}
// => evaluateMatch: converts continuous data into a discrete FSM event string
// => The bridge between the analogue world (amounts) and digital world (events)
public static String evaluateMatch(
double supplierAmount, double expectedAmount, double tolerancePct) {
if (expectedAmount == 0) return "match_fail";
// => No expected amount: guard fails — no goods were received
double delta = Math.abs(supplierAmount - expectedAmount) / expectedAmount;
// => Relative discrepancy as a fraction
return delta <= tolerancePct ? "match_ok" : "match_fail";
// => Ternary: within tolerance → match_ok; outside → match_fail
}
}
// => Demonstrate combined guard + transition
var matchingInv = new Invoice("inv_030", "po_030", 508.0, InvoiceState.MATCHING);
String evt1 = InvoiceFSM.evaluateMatch(508.0, 500.0, 0.02);
// => evt1: "match_ok" (1.6% delta ≤ 2% tolerance)
Optional<Invoice> next1 = InvoiceFSM.apply(matchingInv, evt1);
next1.ifPresent(i -> System.out.println(i.state())); // => Output: MATCHED
String evt2 = InvoiceFSM.evaluateMatch(600.0, 500.0, 0.02);
// => evt2: "match_fail" (20% delta > 2% tolerance)
Optional<Invoice> next2 = InvoiceFSM.apply(matchingInv, evt2);
next2.ifPresent(i -> System.out.println(i.state())); // => Output: DISPUTEDKey Takeaway: The evaluateMatch function converts continuous business data into a discrete FSM event — the bridge between the analogue world (amounts, percentages) and the digital world (event strings).
Why It Matters: Domain patterns should be language-agnostic. The three-way match guard, the immutable value type, the table-driven transition — all of these work regardless of whether you choose a sealed type hierarchy, an enum, or a string literal union, because they are mathematical concepts, not language features. Understanding the pattern means you can implement it wherever your team works.
Example 31: Invoice FSM with Optional-Based Transition
Absent-value idioms — Optional wrapping, nullable return types, and undefined returns — all model the same "valid or rejected" transition contract without exceptions. Each approach communicates that no valid next state exists, letting callers handle the rejection without catching an exception.
import java.util.EnumMap;
import java.util.Map;
import java.util.Optional;
// => InvoiceFSMOptional: demonstrates Optional as an alternative to Result<T>
// => Optional.empty() means the FSM rejected the event — no next state exists
// => Preferred when the caller wants to chain further Optional operations
public final class InvoiceFSMOptional {
private InvoiceFSMOptional() {}
// => Utility class: private constructor prevents instantiation
// => Immutable transition table using EnumMap for efficient enum-keyed lookups
private static final Map<InvoiceState, Map<String, InvoiceState>> TABLE =
buildTable();
private static Map<InvoiceState, Map<String, InvoiceState>> buildTable() {
var t = new EnumMap<InvoiceState, Map<String, InvoiceState>>(InvoiceState.class);
// => Each put registers one state's outgoing transitions
t.put(InvoiceState.REGISTERED,
Map.of("start_match", InvoiceState.MATCHING));
t.put(InvoiceState.MATCHING,
Map.of("match_ok", InvoiceState.MATCHED,
"match_fail", InvoiceState.DISPUTED));
t.put(InvoiceState.DISPUTED,
Map.of("resubmit", InvoiceState.MATCHING));
t.put(InvoiceState.MATCHED,
Map.of("schedule", InvoiceState.SCHEDULED_FOR_PAYMENT));
t.put(InvoiceState.SCHEDULED_FOR_PAYMENT,
Map.of("pay", InvoiceState.PAID));
// => PAID: no entry — terminal state, Optional.empty() on any event
return Map.copyOf(t);
// => Map.copyOf: creates an unmodifiable copy — defensive against mutation
}
// => Pure table-driven transition: returns Optional.of(newInvoice) or Optional.empty()
// => Optional chain: ofNullable wraps null-safe get; map transforms if present
public static Optional<Invoice> apply(Invoice inv, String event) {
return Optional.ofNullable(
TABLE.getOrDefault(inv.state(), Map.of()).get(event))
.map(next -> new Invoice(
inv.id(), inv.poId(), inv.supplierAmount(), next));
// => ofNullable: null → Optional.empty(); non-null → Optional.of(state)
// => map(): applies the Invoice constructor only when state is present
}
// => evaluateMatch: converts continuous data to a discrete event string
// => Architectural seam: keeps tolerance policy separate from FSM structure
public static String evaluateMatch(
double supplierAmount, double expectedAmount, double tolerancePct) {
if (expectedAmount == 0) return "match_fail";
// => No goods received → guard fails unconditionally
double delta = Math.abs(supplierAmount - expectedAmount) / expectedAmount;
// => Relative discrepancy as fraction (e.g. 0.016 = 1.6%)
return delta <= tolerancePct ? "match_ok" : "match_fail";
// => Guard result determines which of the two outgoing events fires
}
}
// => Demonstrate Optional-based dispatch
var matchingInv = new Invoice("inv_031", "po_031", 508.0, InvoiceState.MATCHING);
String evt = InvoiceFSMOptional.evaluateMatch(508.0, 500.0, 0.02);
// => evt: "match_ok" (1.6% delta ≤ 2% tolerance)
Optional<Invoice> next = InvoiceFSMOptional.apply(matchingInv, evt);
next.ifPresent(i -> System.out.println(i.state())); // => Output: MATCHED
// => Rejected transition: pay from MATCHING — MATCHING has no "pay" event
Optional<Invoice> rejected = InvoiceFSMOptional.apply(matchingInv, "pay");
System.out.println(rejected.isEmpty()); // => Output: true (Optional.empty)Key Takeaway: EvaluateMatch/evaluateMatch converts continuous business data into a discrete FSM event — the bridge between the analogue world (amounts, percentages) and the digital world (event strings).
Why It Matters: This bridge function is the key architectural seam. On one side: floating-point arithmetic, tolerance percentages, currency rounding. On the other: the clean match_ok/match_fail event alphabet of the FSM. Keeping these concerns separate means you can change the tolerance policy (say, from 2% to 3%) without touching the FSM structure.
State-Machine Libraries (Examples 32-37)
Example 32: Declarative Machine Configuration with Entry Actions
Separating machine configuration (data) from behaviour (action functions) is the key insight behind libraries like XState, Spring State Machine, and similar. Each language has idiomatic patterns for expressing FSM configuration as data and wiring action implementations separately.
import java.util.EnumMap;
import java.util.Map;
import java.util.Set;
import java.util.function.Consumer;
// => StateConfig: declarative configuration for one state
// => Java record: immutable data — the config is a value, not an object with behaviour
public record StateConfig(
Set<String> validEvents, // => Events this state can accept
String entryActionName, // => Named entry action (null = no action)
boolean terminal // => Terminal states accept no events
) {
// => Static factory for non-terminal states with an entry action
public static StateConfig withEntry(String entryAction, String... events) {
return new StateConfig(Set.of(events), entryAction, false);
// => Set.of: immutable event set — prevents runtime mutation of config
}
// => Static factory for terminal states
public static StateConfig terminal() {
return new StateConfig(Set.of(), null, true);
// => Terminal: no valid events, no entry action needed
}
}
// => Machine config: maps each state to its configuration — pure data, no behaviour
// => This map IS the FSM specification — can be serialised, visualised, or diffed
public static final Map<InvoiceState, StateConfig> INVOICE_MACHINE_CONFIG =
new EnumMap<>(InvoiceState.class) {{
put(InvoiceState.REGISTERED,
StateConfig.withEntry("logRegistered", "start_match"));
// => REGISTERED: one valid event; logs on entry
put(InvoiceState.MATCHING,
StateConfig.withEntry("logMatchingStarted", "match_ok", "match_fail"));
// => MATCHING: two valid events (guard determines which fires); logs on entry
put(InvoiceState.MATCHED,
StateConfig.withEntry("notifyFinance", "schedule"));
// => MATCHED: one valid event; notifies finance on entry
put(InvoiceState.DISPUTED,
StateConfig.withEntry("notifySupplier", "resubmit"));
// => DISPUTED: one valid event; notifies supplier on entry
put(InvoiceState.SCHEDULED_FOR_PAYMENT,
StateConfig.withEntry("notifySupplierPaymentScheduled", "pay"));
put(InvoiceState.PAID,
StateConfig.terminal());
// => PAID: terminal — no valid events, no entry action
}};
// => Action registry: named entry actions wired separately from config
// => Consumer<String>: receives invoiceId — injectable for testing (swap with mock)
public static final Map<String, Consumer<String>> INVOICE_ACTIONS = Map.of(
"logRegistered", id -> System.out.println("Invoice " + id + " registered"),
// => Entry action for REGISTERED: log event for audit trail
"logMatchingStarted", id -> System.out.println("Matching started for invoice " + id),
// => Entry action for MATCHING: kick off async match job in production
"notifyFinance", id -> System.out.println("Finance notified: invoice " + id + " matched"),
// => Entry action for MATCHED: notify AP team to schedule payment
"notifySupplier", id -> System.out.println("Supplier notified: invoice " + id + " disputed"),
// => Entry action for DISPUTED: supplier must correct and resubmit
"notifySupplierPaymentScheduled", id -> System.out.println("Supplier notified: payment scheduled for " + id)
// => Entry action for SCHEDULED_FOR_PAYMENT: inform supplier of payment date
);
// => Demonstrate: query config as data — no execution required
StateConfig matchingCfg = INVOICE_MACHINE_CONFIG.get(InvoiceState.MATCHING);
System.out.println("MATCHING valid events: " + matchingCfg.validEvents());
// => Output: MATCHING valid events: [match_ok, match_fail]
System.out.println("MATCHING entry action: " + matchingCfg.entryActionName());
// => Output: MATCHING entry action: logMatchingStarted
System.out.println("PAID terminal: " + INVOICE_MACHINE_CONFIG.get(InvoiceState.PAID).terminal());
// => Output: PAID terminal: trueKey Takeaway: Declarative machine configuration separates structure (data) from behaviour (action functions) — the machine definition can be inspected, serialised, and version-controlled independently of its action implementations.
Why It Matters: When machine configuration is data, you can query it at runtime to build UI menus showing valid events, generate test cases automatically, and diff machine changes in PRs as structured data rather than imperative code changes. For complex approval workflows where non-engineers need to understand the flow, the declarative config is also living documentation.
Example 33: Guards in XState-Style Config
Named guards separate the guard predicate from the machine configuration — the guard function is supplied separately and resolved by name at runtime, so the machine definition reads like a specification and guard implementations can be swapped independently.
import java.util.List;
import java.util.Map;
import java.util.function.Predicate;
// => MatchContext: immutable record holding the inputs the guard needs to evaluate
// => Bundling inputs into a record lets guard signatures stay uniform across all guards
public record MatchContext(double supplierAmount, double expectedAmount, double tolerancePct) {}
// => supplierAmount: what the supplier claims; expectedAmount: what the PO+GRN computes
// => tolerancePct: policy threshold — 0.02 = 2%
// => Named guard registry: maps guard name → predicate over MatchContext
// => Analogous to XState's guards object passed at machine creation time
public static final Map<String, Predicate<MatchContext>> INVOICE_GUARDS = Map.of(
"matchPasses",
ctx -> {
// => No expected amount: match cannot pass — guard fails immediately
if (ctx.expectedAmount() == 0) return false;
double delta = Math.abs(ctx.supplierAmount() - ctx.expectedAmount())
/ ctx.expectedAmount();
// => Relative discrepancy as a fraction (e.g., 0.016 = 1.6%)
return delta <= ctx.tolerancePct();
// => Within tolerance → guard passes; outside → guard fails
}
);
// => Transition candidate: pairs a target state with an optional named guard
// => Null guardName means unguarded fallback — always fires when reached in the list
public record TransitionCandidate(InvoiceState target, String guardName) {}
// => Guarded transition config: array of candidates evaluated in declaration order
// => First candidate whose guard passes (or has no guard) wins the transition
public static final Map<InvoiceState, Map<String, List<TransitionCandidate>>>
GUARDED_INVOICE_CONFIG = Map.of(
InvoiceState.MATCHING, Map.of(
"evaluate", List.of(
// => First: guarded Matched — fires only if matchPasses returns true
new TransitionCandidate(InvoiceState.MATCHED, "matchPasses"),
// => Second: unguarded Disputed — fires if matchPasses returned false
new TransitionCandidate(InvoiceState.DISPUTED, null)
)
)
);
// => Guard resolver: evaluates candidates in order, returns first matching target
// => Mirrors XState's guard resolution algorithm without the XState runtime
public static InvoiceState resolveGuardedTransition(
InvoiceState current, String event, MatchContext ctx) {
var candidates = GUARDED_INVOICE_CONFIG
.getOrDefault(current, Map.of())
.getOrDefault(event, List.of());
// => candidates: ordered list of (target, guardName) pairs for this state+event
for (var candidate : candidates) {
if (candidate.guardName() == null) return candidate.target();
// => Unguarded candidate: always wins as fallback
var guard = INVOICE_GUARDS.get(candidate.guardName());
// => Look up named guard from registry — null if name not registered
if (guard != null && guard.test(ctx)) return candidate.target();
// => Guard found and passes: this candidate wins
}
throw new IllegalStateException("No transition matched for " + current + " + " + event);
// => Should not happen if config is complete — treat as a wiring error
}
// => 1% delta (505 vs 500): matchPasses returns true → Matched wins
var passingCtx = new MatchContext(505, 500, 0.02);
System.out.println(resolveGuardedTransition(InvoiceState.MATCHING, "evaluate", passingCtx));
// => Output: MATCHED
// => 20% delta (600 vs 500): matchPasses returns false → fallback Disputed wins
var failingCtx = new MatchContext(600, 500, 0.02);
System.out.println(resolveGuardedTransition(InvoiceState.MATCHING, "evaluate", failingCtx));
// => Output: DISPUTEDKey Takeaway: Named guards in a configuration-driven machine make guard logic inspectable and replaceable — the machine config reads like a specification, and guard implementations can be swapped without changing the machine structure.
Why It Matters: In a configuration-driven machine, the same machine config can run with a strict tolerance guard in production and a permissive tolerance guard in testing — just swap the guard implementation. This decoupling means you can test every state transition independently of the specific tolerance value, then integration-test the guard separately.
Example 34: State Entry Actions as Notification Triggers
Entry actions are the natural place to trigger notifications. This example shows how to structure entry actions for the Invoice machine so they can be tested without sending real emails.
stateDiagram-v2
Matching --> Disputed: match_fail
note right of Disputed
Entry action fires:
Notify supplier of dispute
end note
Matching --> Matched: match_ok
note right of Matched
Entry action fires:
Notify AP team to schedule
end note
Matched --> ScheduledForPayment: schedule
note right of ScheduledForPayment
Entry action fires:
Notify supplier of payment date
end note
classDef matching fill:#DE8F05,stroke:#000,color:#000
classDef matched fill:#029E73,stroke:#000,color:#fff
classDef disputed fill:#CC78BC,stroke:#000,color:#fff
classDef scheduled fill:#CA9161,stroke:#000,color:#fff
class Matching matching
class Matched matched
class Disputed disputed
class ScheduledForPayment scheduled
import java.util.Optional;
// => Notifier interface: abstraction over email, EDI, webhook, and console sinks
// => Dependency injection: production code wires real SMTP; tests wire a recording mock
@FunctionalInterface
public interface Notifier {
void send(String to, String message);
// => to: recipient role ("system", "supplier", "finance")
// => message: human-readable event description for audit trail
}
// => Console notifier for development and unit test scaffolding
// => Lambda satisfies the @FunctionalInterface — no boilerplate class needed
public static final Notifier CONSOLE_NOTIFIER =
(to, message) -> System.out.printf("[NOTIFY] %s: %s%n", to, message);
// => In tests: replace with a mock that accumulates (to, message) pairs for assertion
// => Entry action for each Invoice state: fires immediately after the state is entered
// => Switch expression (Java 14+): exhaustive over all known InvoiceState values
public static void invoiceEntryAction(
InvoiceState state, Invoice invoice, Notifier notifier) {
switch (state) {
case MATCHING ->
notifier.send("system",
"Invoice " + invoice.id() + " entering three-way match");
// => Triggers the async match job in production; logs in dev
case DISPUTED ->
notifier.send("supplier",
"Invoice " + invoice.id() + " disputed — please review and resubmit");
// => Supplier must correct amounts and resubmit before the limit is reached
case SCHEDULED_FOR_PAYMENT ->
notifier.send("finance",
"Invoice " + invoice.id() + " scheduled for payment run");
// => Finance team confirmation of upcoming disbursement
case PAID ->
notifier.send("supplier",
"Invoice " + invoice.id() + " paid — check your bank account");
// => Final confirmation; supplier's accounts-receivable can be reconciled
default -> {} // => REGISTERED, MATCHED: no immediate notification required
}
}
// => Result<T>: sealed hierarchy for explicit success/failure without exceptions
// => Java sealed permits (Java 17+): exhaustive pattern matching in callers
public sealed interface Result<T> permits Result.Ok, Result.Err {
record Ok<T>(T value) implements Result<T> {}
// => Ok wraps the successful value — callers unwrap via pattern match
record Err<T>(String error) implements Result<T> {}
// => Err wraps the rejection reason — no exception overhead
}
// => Transition with entry action: pure FSM step + side-effecting notification
public static Result<Invoice> transitionInvoice(
Invoice inv, String event, Notifier notifier) {
var nextState = Optional.ofNullable(
INVOICE_TRANSITIONS.getOrDefault(inv.state(), Map.of()).get(event));
// => Look up target state; empty Optional means the event is forbidden here
if (nextState.isEmpty())
return new Result.Err<>(inv.state() + " --" + event + "--> (forbidden)");
// => Forbidden transition: return error without any side effects
var newInv = new Invoice(inv.id(), inv.poId(), inv.supplierAmount(), nextState.get());
// => Immutable update: new Invoice record with updated state field
invoiceEntryAction(nextState.get(), newInv, notifier);
// => Fire entry action for the new state — notifier is injected for testability
return new Result.Ok<>(newInv);
// => Return the new invoice wrapped in Ok
}
var inv = new Invoice("inv_004", "po_004", 500.0, InvoiceState.REGISTERED);
transitionInvoice(inv, "start_match", CONSOLE_NOTIFIER);
// => Output: [NOTIFY] system: Invoice inv_004 entering three-way matchKey Takeaway: Injecting the notifier as a dependency makes entry actions testable — swap the real notifier for a recording mock in unit tests without touching the FSM logic.
Why It Matters: The FSM transition logic and the notification side effect have different failure modes. The FSM transition is a pure computation that always succeeds given valid input. The notification might fail due to network issues. Injecting the notifier lets you test both independently and combine them only at the application layer.
Example 35: Modelling Invoice Resubmission History
An invoice that goes through Disputed → Matching → Matched → Disputed cycles needs a resubmission counter — the FSM state alone does not capture this history.
// => InvoiceWithHistory: extends Invoice with resubmission tracking context
// => Java record: immutable — every resubmission returns a new instance
public record InvoiceWithHistory(
String id, // => Format: inv_<uuid>
String poId, // => Linked purchase order
double supplierAmount, // => Amount supplier claims (USD)
InvoiceState state, // => Current FSM state
int resubmissionCount, // => How many times supplier has resubmitted
int maxResubmissions // => Policy limit before escalation to manual review
) {}
// => Guard: can the invoice be resubmitted under current policy?
// => Pure function — no state mutation, independently testable
public static boolean canResubmit(InvoiceWithHistory inv) {
return inv.resubmissionCount() < inv.maxResubmissions();
// => Below limit: resubmission allowed
// => At or above limit: escalation to manual review required
}
// => Resubmit transition with counter increment
// => Returns Result<InvoiceWithHistory>: either the updated invoice or a rejection reason
public static Result<InvoiceWithHistory> resubmitInvoice(InvoiceWithHistory inv) {
if (inv.state() != InvoiceState.DISPUTED)
return new Result.Err<>("Cannot resubmit invoice in state " + inv.state());
// => FSM guard: resubmission is only valid from the DISPUTED state
if (!canResubmit(inv))
return new Result.Err<>(
"Invoice " + inv.id() + " exceeded resubmission limit (" + inv.maxResubmissions() + ")");
// => Policy guard: too many resubmissions — escalate to manual review
return new Result.Ok<>(new InvoiceWithHistory(
inv.id(), inv.poId(), inv.supplierAmount(),
InvoiceState.MATCHING, // => Return to MATCHING for re-evaluation
inv.resubmissionCount() + 1, // => Increment counter on each resubmission
inv.maxResubmissions() // => Policy limit unchanged
));
// => Immutable update: new record instance, original inv is unchanged
}
var inv = new InvoiceWithHistory("inv_005", "po_005", 600.0,
InvoiceState.DISPUTED, 2, 3);
// => resubmissionCount=2, maxResubmissions=3 → one attempt remaining
var r1 = resubmitInvoice(inv);
if (r1 instanceof Result.Ok<InvoiceWithHistory> ok)
System.out.printf("%s, count: %d%n", ok.value().state(), ok.value().resubmissionCount());
// => Output: MATCHING, count: 3
var r2 = resubmitInvoice(r1 instanceof Result.Ok<InvoiceWithHistory> ok2 ? ok2.value() : inv);
// => r1.value has count=3 which equals maxResubmissions=3 → canResubmit returns false
if (r2 instanceof Result.Err<InvoiceWithHistory> err)
System.out.println(err.error());
// => Output: Invoice inv_005 exceeded resubmission limit (3)Key Takeaway: Counters and timestamps that accumulate across state transitions belong in the context object alongside the state — they are part of the machine's memory, not its current state.
Why It Matters: Without a resubmission limit, a supplier could resubmit indefinitely, never resolving the discrepancy. The limit is a policy that the FSM enforces as a guard — when the counter reaches the maximum, resubmission is rejected and the invoice is escalated to manual review. The FSM makes this policy explicit and auditable.
Example 36: FSM as Protocol Enforcement
The FSM enforces the invoice lifecycle as a strict protocol. Events sent out of order are rejected — this prevents integration bugs where an upstream system sends events in the wrong sequence.
stateDiagram-v2
[*] --> Registered
Registered --> Matching: start_match ✓
Matching --> Matched: match_ok ✓
Matching --> Disputed: match_fail ✓
Disputed --> Matching: resubmit ✓
Matched --> ScheduledForPayment: schedule ✓
ScheduledForPayment --> Paid: pay ✓
Registered --> Paid: pay ✗ REJECTED
Matching --> Paid: pay ✗ REJECTED
classDef valid fill:#029E73,stroke:#000,color:#fff
classDef waiting fill:#DE8F05,stroke:#000,color:#000
classDef disputed fill:#CC78BC,stroke:#000,color:#fff
classDef terminal fill:#CA9161,stroke:#000,color:#fff
class Registered,ScheduledForPayment waiting
class Matching,Matched valid
class Disputed disputed
class Paid terminal
import java.util.Map;
import java.util.Optional;
import java.util.stream.Collectors;
// => ProtocolResult: encapsulates the outcome of a protocol check
// => Sealed hierarchy: callers pattern-match on Allowed vs Rejected
public sealed interface ProtocolResult permits ProtocolResult.Allowed, ProtocolResult.Rejected {
// => Allowed: transition is valid — newState is the target InvoiceState
record Allowed(InvoiceState newState) implements ProtocolResult {}
// => Rejected: transition is forbidden — reason explains what IS allowed
record Rejected(String reason) implements ProtocolResult {}
}
// => Protocol enforcer: wraps the FSM to produce human-readable rejection messages
// => API controllers call this and map Rejected → HTTP 409; Allowed → HTTP 200
public static ProtocolResult enforceInvoiceProtocol(Invoice inv, String event) {
var stateTransitions = INVOICE_TRANSITIONS.getOrDefault(inv.state(), Map.of());
// => Fetch all valid transitions for the current state
return Optional.ofNullable(stateTransitions.get(event))
.map(ProtocolResult.Allowed::new)
// => Event found: transition is allowed — wrap target state in Allowed
.orElseGet(() -> {
var allowedEvents = stateTransitions.keySet().stream()
.sorted().collect(Collectors.joining(", "));
// => Build sorted list of events that ARE permitted from this state
return new ProtocolResult.Rejected(
"Protocol violation: cannot send '" + event + "' to invoice in state '"
+ inv.state() + "'. Allowed events: [" + allowedEvents + "]");
// => Detailed rejection: tells the caller what IS allowed — actionable for debugging
});
}
var inv = new Invoice("inv_006", "po_006", 500.0, InvoiceState.REGISTERED);
// => Correct: start_match from REGISTERED
var r1 = enforceInvoiceProtocol(inv, "start_match");
System.out.println(r1 instanceof ProtocolResult.Allowed a ? "allowed → " + a.newState() : "rejected");
// => Output: allowed → MATCHING
// => Incorrect: partner sends 'pay' before matching
var r2 = enforceInvoiceProtocol(inv, "pay");
System.out.println(r2 instanceof ProtocolResult.Rejected rej ? rej.reason() : "allowed");
// => Output: Protocol violation: cannot send 'pay' to invoice in state 'REGISTERED'.
// => Allowed events: [start_match]
// => Incorrect: double-match attempt from already-Matched state
var matched = new Invoice(inv.id(), inv.poId(), inv.supplierAmount(), InvoiceState.MATCHED);
var r3 = enforceInvoiceProtocol(matched, "match_ok");
if (r3 instanceof ProtocolResult.Rejected rej3) System.out.println(rej3.reason());
// => Output: Protocol violation: cannot send 'match_ok' to invoice in state 'MATCHED'.
// => Allowed events: [schedule]Key Takeaway: The FSM's rejection of invalid transitions is the first line of defence against integration bugs — the protocol enforcer converts FSM rejections into actionable API error messages.
Why It Matters: In a microservices environment, the invoice service receives events from multiple upstream systems. Without protocol enforcement, an event from a misconfigured consumer could move an invoice backwards (e.g., start_match on an already-Matched invoice). The FSM prevents this structurally — no special case code needed for each possible misconfiguration.
Connecting PO and Invoice Machines (Examples 37-44)
Example 37: PO Lifecycle Coverage — PartiallyReceived State
The full PO machine includes PartiallyReceived for cases where a supplier ships goods in multiple batches. This intermediate example extends the beginner machine.
stateDiagram-v2
[*] --> Acknowledged
Acknowledged --> PartiallyReceived: receive_partial
PartiallyReceived --> PartiallyReceived: receive_partial
PartiallyReceived --> Received: receive_final
Acknowledged --> Received: receive_final
Received --> Invoiced: invoice_matched
Invoiced --> Closed: close
classDef active fill:#029E73,stroke:#000,color:#fff
classDef partial fill:#CC78BC,stroke:#000,color:#fff
classDef terminal fill:#CA9161,stroke:#000,color:#fff
class Acknowledged,Received,Invoiced active
class PartiallyReceived partial
class Closed terminal
import java.util.EnumMap;
import java.util.Map;
// => Full PO state: extends the beginner machine with receiving substates
// => Java enum: each constant is a named, type-safe state identifier
public enum FullPOState {
DRAFT, AWAITING_APPROVAL, APPROVED, ISSUED,
ACKNOWLEDGED,
PARTIALLY_RECEIVED, // => Some goods received; more shipments expected from supplier
RECEIVED, // => All goods received against the PO quantity
INVOICED, // => Matched invoice received from supplier
PAID, // => Payment disbursed to supplier
CLOSED, // => PO fully complete — terminal state
CANCELLED, DISPUTED
}
// => Full PO event: extended with receiving and lifecycle completion events
public enum FullPOEvent {
SUBMIT, APPROVE, REJECT, ISSUE, ACKNOWLEDGE, CANCEL, DISPUTE,
PARTIAL_RECEIVE, // => Some goods received (more expected; triggers self-loop)
FULL_RECEIVE, // => All goods received — final shipment against PO
INVOICE_MATCHED, // => Invoice FSM emits InvoiceMatched; PO advances to INVOICED
PAY, CLOSE
}
// => Receiving transitions: receiving substates from ACKNOWLEDGED and PARTIALLY_RECEIVED
// => EnumMap<EnumMap>: type-safe, memory-efficient nested map for enum keys
public static final Map<FullPOState, Map<FullPOEvent, FullPOState>> FULL_PO_TRANSITIONS;
static {
FULL_PO_TRANSITIONS = new EnumMap<>(FullPOState.class);
// => ACKNOWLEDGED: supplier ships first partial batch or entire order at once
FULL_PO_TRANSITIONS.put(FullPOState.ACKNOWLEDGED, new EnumMap<>(Map.of(
FullPOEvent.PARTIAL_RECEIVE, FullPOState.PARTIALLY_RECEIVED,
// => First partial shipment → PARTIALLY_RECEIVED; more expected
FullPOEvent.FULL_RECEIVE, FullPOState.RECEIVED,
// => Single complete shipment → RECEIVED; all goods in
FullPOEvent.CANCEL, FullPOState.CANCELLED,
FullPOEvent.DISPUTE, FullPOState.DISPUTED
)));
// => PARTIALLY_RECEIVED: self-loop for each additional partial shipment
FULL_PO_TRANSITIONS.put(FullPOState.PARTIALLY_RECEIVED, new EnumMap<>(Map.of(
FullPOEvent.PARTIAL_RECEIVE, FullPOState.PARTIALLY_RECEIVED,
// => Self-loop: another partial batch received — machine stays in PARTIALLY_RECEIVED
FullPOEvent.FULL_RECEIVE, FullPOState.RECEIVED,
// => Final batch received — all PO quantity satisfied
FullPOEvent.CANCEL, FullPOState.CANCELLED,
FullPOEvent.DISPUTE, FullPOState.DISPUTED
)));
// => RECEIVED: wait for Invoice FSM to signal match completion
FULL_PO_TRANSITIONS.put(FullPOState.RECEIVED, new EnumMap<>(Map.of(
FullPOEvent.INVOICE_MATCHED, FullPOState.INVOICED,
// => Invoice FSM emits InvoiceMatched event → PO advances to INVOICED
FullPOEvent.DISPUTE, FullPOState.DISPUTED
)));
FULL_PO_TRANSITIONS.put(FullPOState.INVOICED, new EnumMap<>(Map.of(
FullPOEvent.PAY, FullPOState.PAID,
FullPOEvent.DISPUTE, FullPOState.DISPUTED
)));
FULL_PO_TRANSITIONS.put(FullPOState.PAID, new EnumMap<>(Map.of(
FullPOEvent.CLOSE, FullPOState.CLOSED
// => CLOSED is terminal — no outgoing transitions
)));
}
// => Demonstrate the self-loop: PARTIALLY_RECEIVED → PARTIALLY_RECEIVED
var next = FULL_PO_TRANSITIONS
.get(FullPOState.PARTIALLY_RECEIVED)
.get(FullPOEvent.PARTIAL_RECEIVE);
System.out.println(next); // => Output: PARTIALLY_RECEIVED (self-loop for additional shipments)Key Takeaway: Self-loops in an FSM model iterative real-world operations — each partial shipment fires the same event, and the machine stays in the same state until the full-receive event arrives.
Why It Matters: Without the self-loop, you would need a counter ("shipment 1 of 3") to track partial receipt progress, and the FSM would need a different state per count — combinatorial explosion. The self-loop on PartiallyReceived keeps the state machine flat while the application layer tracks the delivery count separately in the context object.
Example 38: Combining PO and Invoice with Event Bus
In production, the two FSMs live in separate services. They coordinate via domain events on a message bus. This example simulates the coordination.
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.function.Consumer;
// => DomainEvent: minimal record for in-process event bus simulation
// => In production: replace Map payload with typed event classes per Kafka schema registry
public record DomainEvent(String kind, Map<String, Object> payload) {}
// => kind: string discriminator (e.g., "InvoiceMatched")
// => payload: loosely typed map for flexibility in this simulation
// => EventBus: minimal in-memory publish/subscribe coordination mechanism
// => In production: replace with Kafka/RabbitMQ/SNS producer+consumer wiring
public class EventBus {
private final Map<String, List<Consumer<DomainEvent>>> handlers = new HashMap<>();
// => handlers keyed by event kind — supports multiple handlers per kind
public void subscribe(String kind, Consumer<DomainEvent> handler) {
handlers.computeIfAbsent(kind, k -> new ArrayList<>()).add(handler);
// => computeIfAbsent: thread-safe initialisation of handler list for new kinds
}
public void publish(DomainEvent event) {
handlers.getOrDefault(event.kind(), List.of())
.forEach(h -> h.accept(event));
// => Synchronous dispatch for simplicity; production uses async consumer threads
}
}
var bus = new EventBus();
// => Shared bus: both contexts (invoicing and purchasing) register against the same bus
// => Purchasing context: subscribes to InvoiceMatched and advances PO state
bus.subscribe("InvoiceMatched", e -> {
var poId = (String) e.payload().get("poId");
var invoiceId = (String) e.payload().get("invoiceId");
// => In production: load PO aggregate from repository, apply invoice_matched event, persist
System.out.printf(
"PO %s receives InvoiceMatched from invoice %s → transition to INVOICED%n",
poId, invoiceId);
// => The purchasing bounded context reacts independently — no direct call to invoicing service
});
// => Invoice context: publishes InvoiceMatched when matching succeeds
// => This is the only coupling between contexts — the event schema
void invoiceMatchSucceeded(EventBus b, String invoiceId, String poId) {
b.publish(new DomainEvent("InvoiceMatched",
Map.of("invoiceId", invoiceId, "poId", poId)));
// => Domain event flows from invoicing context → message bus → purchasing context
}
invoiceMatchSucceeded(bus, "inv_bus_01", "po_bus_01");
// => Output: PO po_bus_01 receives InvoiceMatched from invoice inv_bus_01 → transition to INVOICEDKey Takeaway: Domain events are the coupling mechanism between bounded contexts — each FSM publishes what happened; other FSMs subscribe and decide how to react.
Why It Matters: Direct coupling (invoicing context calling purchasing service directly) creates a distributed monolith. Domain events through a bus allow each service to evolve independently — the purchasing service does not know or care that the invoicing service exists, only that InvoiceMatched events arrive on the bus.
Example 39: Testing the Invoice-PO Coordination
Unit-testing the coordination between Invoice and PurchaseOrder FSMs without a real event bus.
// => InvoiceMatchedEvent: typed domain event record — replaces loosely-typed DomainEvent for tests
// => Strongly typed: compiler catches field name typos at compile time, not at runtime
public record InvoiceMatchedEvent(
String invoiceId, // => Invoice that passed the three-way match
String poId, // => PO linked to the matched invoice
String timestamp // => ISO-8601 UTC timestamp of when matching completed
) {}
// => Handle the InvoiceMatched event against a PO state
// => Pure function: takes current PO state + event, returns Result with new state
// => No I/O: no database, no Kafka — domain logic only, trivially testable
public static Result<FullPOState> handleInvoiceMatched(
FullPOState current, InvoiceMatchedEvent event) {
if (!(current instanceof FullPOState) ||
FULL_PO_TRANSITIONS.getOrDefault(current, Map.of()).get("invoice_matched") == null) {
return new Result.Err<>(
"PO cannot accept InvoiceMatched in state " + current.getClass().getSimpleName()
+ " — expected RECEIVED");
// => Rejection: PO must be in RECEIVED state to accept InvoiceMatched
}
var next = FULL_PO_TRANSITIONS.get(current).get("invoice_matched");
// => next is INVOICED — the only valid target from RECEIVED
return new Result.Ok<>(next);
// => Return new state wrapped in Ok — no side effects
}
// => Test: positive path — PO in RECEIVED advances to INVOICED on InvoiceMatched
static void testInvoiceMatchedUpdatesPO() {
var event = new InvoiceMatchedEvent("inv_test01", "po_test01", "2026-01-20T11:00:00Z");
// => Typed test data: no Map<String, Object> — compiler enforces field presence
// => Positive test: RECEIVED → INVOICED
var r1 = handleInvoiceMatched(FullPOState.RECEIVED, event);
if (r1 instanceof Result.Err<FullPOState> err)
throw new AssertionError("Expected Ok but got: " + err.error());
System.out.println("PASS: PO transitions to INVOICED on InvoiceMatched");
// => Assertion: PO moves to INVOICED
System.out.println(" New state: " + ((Result.Ok<FullPOState>) r1).value());
// => Output: INVOICED
// => Negative test: PO must be in RECEIVED (not ACKNOWLEDGED) to accept InvoiceMatched
var r2 = handleInvoiceMatched(FullPOState.ACKNOWLEDGED, event);
if (r2 instanceof Result.Ok<FullPOState>)
throw new AssertionError("Expected Err for premature InvoiceMatched");
System.out.println("PASS: InvoiceMatched rejected when PO not in RECEIVED");
System.out.println(" Error: " + ((Result.Err<FullPOState>) r2).error());
// => Output: PO cannot accept InvoiceMatched in state Acknowledged — expected RECEIVED
}
testInvoiceMatchedUpdatesPO();
// => Output:
// => PASS: PO transitions to INVOICED on InvoiceMatched
// => New state: INVOICED
// => PASS: InvoiceMatched rejected when PO not in RECEIVED
// => Error: PO cannot accept InvoiceMatched in state Acknowledged — expected RECEIVEDKey Takeaway: Testing coordination between two FSMs requires only the event handler function and typed test data — no running services, no Kafka, no database.
Why It Matters: The value of modelling domain interactions as pure event handlers is that they are trivially testable. The real Kafka infrastructure is an adapter concern, not a domain logic concern. When the domain logic is correct, wiring it to Kafka is straightforward; testing the wiring is a separate integration test concern.
Example 40: Validation Error Accumulation
Rather than returning on the first validation failure, accumulate all errors and return them together — better UX for complex invoice validation.
stateDiagram-v2
[*] --> Validating
Validating --> Valid: all checks pass
Validating --> AccumulatingErrors: any check fails
AccumulatingErrors --> AccumulatingErrors: more checks fail
AccumulatingErrors --> Invalid: all checks complete
Valid --> Disputed: resubmit allowed
Invalid --> StillDisputed: resubmit blocked
classDef validating fill:#DE8F05,stroke:#000,color:#000
classDef valid fill:#029E73,stroke:#000,color:#fff
classDef invalid fill:#CA9161,stroke:#000,color:#fff
classDef acc fill:#CC78BC,stroke:#000,color:#fff
class Validating validating
class AccumulatingErrors acc
class Valid,Disputed valid
class Invalid,StillDisputed invalid
import java.util.ArrayList;
import java.util.List;
// => ValidationContext: bundles all inputs needed for a multi-check validation pass
// => Java record: immutable holder — validation is a pure read-only operation
public record ValidationContext(
InvoiceWithHistory invoice, // => The invoice being validated for resubmission
List<GRNLine> grnLines, // => Goods received — used to compute expected amount
List<POLineForMatch> poLines, // => PO lines — provide agreed unit prices
Tolerance tolerance // => Acceptable match discrepancy (e.g. 2%)
) {}
// => validateInvoiceForSubmission: collects ALL validation failures in one pass
// => Pure function: no side effects, returns a list of human-readable error strings
// => Empty list means all checks passed — caller decides whether to proceed
public static List<String> validateInvoiceForSubmission(ValidationContext ctx) {
List<String> errors = new ArrayList<>();
// => Mutable local accumulator: final result is exposed via List.copyOf()
InvoiceWithHistory inv = ctx.invoice();
// => Check 1: FSM state guard — only Disputed invoices may be resubmitted
if (inv.state() != InvoiceState.DISPUTED) {
errors.add("Invoice is in state '" + inv.state() +
"'; only DISPUTED invoices can be resubmitted");
// => Wrong state: collect error and continue — do not short-circuit
}
// => Check 2: policy guard — resubmission counter must be below the limit
if (!canResubmit(inv)) {
errors.add("Resubmission limit (" + inv.maxResubmissions() + ") reached");
// => Too many resubmissions: supplier must escalate to manual review
}
// => Check 3: data integrity — supplier amount must be positive
if (inv.supplierAmount() <= 0) {
errors.add("Supplier amount must be > 0 (got " + inv.supplierAmount() + ")");
// => Zero or negative amount: clearly wrong regardless of match outcome
}
// => Check 4: business guard — corrected amount must now pass the three-way match
double expected = computeExpectedAmount(ctx.grnLines(), ctx.poLines());
// => Re-compute expected amount from current GRN and PO data
if (expected > 0 && !threeWayMatchPasses(inv, ctx.grnLines(), ctx.poLines(), ctx.tolerance())) {
double delta = Math.abs(inv.supplierAmount() - expected) / expected;
// => Relative discrepancy as a fraction (e.g. 0.14 = 14%)
errors.add(String.format("Amount still outside tolerance: %.1f%% vs %.1f%% max",
delta * 100, ctx.tolerance().percentage() * 100));
// => Resubmitting a still-failing amount would immediately re-dispute the invoice
}
return List.copyOf(errors);
// => List.copyOf: returns an unmodifiable snapshot — callers cannot mutate
}
// => Demonstrate: invoice in wrong state, at limit, negative amount, bad match
var ctx = new ValidationContext(
new InvoiceWithHistory("inv_val01", "po_val01", -100.0,
InvoiceState.REGISTERED, 3, 3),
// => state=REGISTERED (not DISPUTED), count=3=maxResubmissions, amount=-100
List.of(new GRNLine("ELC-0042", 10)),
List.of(new POLineForMatch("ELC-0042", 50.0)),
new Tolerance(0.02)
);
List<String> errors = validateInvoiceForSubmission(ctx);
errors.forEach(e -> System.out.println("- " + e));
// => Output (one line per failed check):
// => - Invoice is in state 'REGISTERED'; only DISPUTED invoices can be resubmitted
// => - Resubmission limit (3) reached
// => - Supplier amount must be > 0 (got -100.0)
// => - Amount still outside tolerance: ...% vs 2.0% maxKey Takeaway: Accumulating validation errors before attempting a transition gives callers actionable feedback — fix all issues at once rather than discovering them one at a time.
Why It Matters: In a UI where a supplier is correcting a disputed invoice, returning the first error and forcing a round-trip to discover the next error is poor UX. Collecting all errors in one pass means the supplier sees everything they need to fix simultaneously. The FSM transition still validates state — this pre-validation is a separate concern for bulk feedback.
Example 41: State Machine Composition — Invoice Inside PO Lifecycle
The Invoice FSM runs inside the PO lifecycle: a PO moves from Received to Invoiced only after the Invoice FSM completes its Matched → ScheduledForPayment → Paid cycle. This composition is modelled by making the PO FSM listen for the Invoice terminal state.
stateDiagram-v2
state "PO FSM (Outer)" as PO {
[*] --> Received
Received --> Invoiced: InvoicePaid event
Invoiced --> Closed: close
}
state "Invoice FSM (Inner)" as Inv {
[*] --> Matched
Matched --> ScheduledForPayment: schedule
ScheduledForPayment --> Paid: pay
}
Inv --> PO: Paid → emits InvoicePaid domain event
classDef po fill:#0173B2,stroke:#000,color:#fff
classDef inv fill:#029E73,stroke:#000,color:#fff
class Received,Invoiced,Closed po
class Matched,ScheduledForPayment,Paid inv
import java.util.Optional;
// => P2PWorkflow: aggregate tracking both outer (PO) and inner (Invoice) FSM state
// => Java record: immutable — every transition returns a new P2PWorkflow instance
public record P2PWorkflow(
PurchaseOrder po, // => Outer FSM: PO lifecycle state
Optional<Invoice> invoice // => Inner FSM: present once registered, absent before
) {
// => Static factory: workflow begins with only a PO — invoice not yet registered
public static P2PWorkflow ofPO(PurchaseOrder po) {
return new P2PWorkflow(po, Optional.empty());
// => Optional.empty(): inner FSM not yet started
}
}
// => Step 1: registerInvoice — starts the inner FSM
// => Outer guard: PO must be in RECEIVED state (all goods confirmed) before invoicing
public static Result<P2PWorkflow> registerInvoice(
P2PWorkflow workflow, String invoiceId, double supplierAmount) {
if (workflow.po().state() != ExtendedPOState.RECEIVED) {
return new Result.Err<>(
"Cannot register invoice: PO goods not yet received (state: " +
workflow.po().state() + ")");
// => Outer FSM guard: inner machine cannot start until outer is in RECEIVED
}
Invoice invoice = new Invoice(invoiceId, workflow.po().id(),
supplierAmount, InvoiceState.REGISTERED);
// => Inner FSM starts in REGISTERED — the first state of the invoice lifecycle
return new Result.Ok<>(new P2PWorkflow(workflow.po(), Optional.of(invoice)));
// => Return new workflow with invoice populated — outer PO state unchanged
}
// => Step 2: advancePOOnInvoicePaid — outer FSM reacts to inner FSM terminal state
// => Guard: Invoice must be in PAID state (inner FSM complete) before PO advances
public static Result<P2PWorkflow> advancePOOnInvoicePaid(P2PWorkflow workflow) {
boolean invoicePaid = workflow.invoice()
.map(inv -> inv.state() == InvoiceState.PAID)
.orElse(false);
// => map: unwrap Optional<Invoice> and check state; orElse(false): absent = not paid
if (!invoicePaid) {
return new Result.Err<>("Cannot advance PO: invoice not yet paid");
// => Inner FSM not terminal: outer FSM must wait
}
PurchaseOrder updatedPO = new PurchaseOrder(
workflow.po().id(), workflow.po().totalAmount(), ExtendedPOState.INVOICED);
// => Outer FSM advances: RECEIVED → INVOICED (inner FSM terminal triggers this)
return new Result.Ok<>(new P2PWorkflow(updatedPO, workflow.invoice()));
// => New workflow: PO updated, invoice unchanged (remains in PAID)
}
// => Demonstrate: register invoice, then advance PO after payment
var wf = P2PWorkflow.ofPO(
new PurchaseOrder("po_comp01", 500.0, ExtendedPOState.RECEIVED));
// => PO in RECEIVED: goods confirmed, ready for invoicing
var r1 = registerInvoice(wf, "inv_comp01", 505.0);
if (r1 instanceof Result.Ok<P2PWorkflow> ok1) {
System.out.println(ok1.value().invoice().map(i -> i.state()).orElse(null));
// => Output: REGISTERED (inner FSM started)
}Key Takeaway: Nested FSM composition — an inner machine controlling when an outer machine advances — models real-world subprocess coordination without a general-purpose workflow engine.
Why It Matters: Many procurement tools require a separate workflow engine (e.g., Camunda) to manage subprocess coordination. A composition of two FSMs achieves the same structure with plain code: the outer machine waits for the inner machine's terminal state before advancing. For a bounded, well-understood workflow like P2P, this is simpler and more auditable than a general-purpose engine.
Example 42: Timeout Guards
Some transitions have time-based guards: an invoice must be matched within 30 days of registration or it auto-disputes.
import java.time.Instant;
import java.time.temporal.ChronoUnit;
// => InvoiceTimed: extends Invoice with the registration timestamp for timeout checks
// => Java record: immutable value object — registeredAt is set once and never changed
public record InvoiceTimed(
String id, // => Format: inv_<uuid>
String poId, // => Linked PO aggregate
double supplierAmount, // => Amount supplier claims (USD)
InvoiceState state, // => Current FSM state
Instant registeredAt // => When invoice was registered — clock-in for timeout
) {}
// => Policy constant: match must complete within 30 days of registration
public static final int MATCH_DEADLINE_DAYS = 30;
// => Centralising the constant makes policy changes a one-line edit
// => isMatchOverdue: pure guard — injectable Instant 'now' makes it deterministically testable
// => Production: pass Instant.now(); tests: pass a fixed Instant
public static boolean isMatchOverdue(InvoiceTimed invoice, Instant now) {
Instant deadline = invoice.registeredAt().plus(MATCH_DEADLINE_DAYS, ChronoUnit.DAYS);
// => Deadline: registeredAt + 30 days computed via ChronoUnit for precision
return now.isAfter(deadline);
// => True if current instant is past the 30-day deadline
}
// => TimeoutResult: pairs the (possibly updated) invoice with an optional timeout reason
// => Java record: immutable pair — no mutable tuple type in standard Java
public record TimeoutResult(InvoiceTimed invoice, String reason) {
// => reason: "timeout" if guard fired; null if within deadline or wrong state
}
// => autoDisputeIfOverdue: applies the timeout guard in MATCHING state only
// => Pure function: no side effects — caller decides whether to persist the result
public static TimeoutResult autoDisputeIfOverdue(InvoiceTimed invoice, Instant now) {
if (invoice.state() != InvoiceState.MATCHING) {
return new TimeoutResult(invoice, null);
// => Not MATCHING: timeout check is irrelevant — return unchanged
}
if (isMatchOverdue(invoice, now)) {
InvoiceTimed disputed = new InvoiceTimed(invoice.id(), invoice.poId(),
invoice.supplierAmount(), InvoiceState.DISPUTED, invoice.registeredAt());
// => Timeout fired: auto-transition to DISPUTED — no explicit event from caller
return new TimeoutResult(disputed, "timeout");
// => "timeout" reason: caller can log or emit a TimeoutDisputed domain event
}
return new TimeoutResult(invoice, null);
// => Within deadline: no state change — invoice stays in MATCHING
}
// => Test with an overdue invoice: registered 80 days before the check date
var oldInv = new InvoiceTimed("inv_old", "po_old", 500.0,
InvoiceState.MATCHING, Instant.parse("2025-11-01T00:00:00Z"));
// => registeredAt: 2025-11-01; now: 2026-01-20 = 80 days later → overdue
var r1 = autoDisputeIfOverdue(oldInv, Instant.parse("2026-01-20T00:00:00Z"));
System.out.println(r1.invoice().state() + " / " + r1.reason());
// => Output: DISPUTED / timeout
// => Test with a recent invoice: registered 5 days before the check date
var recentInv = new InvoiceTimed("inv_new", "po_new", 500.0,
InvoiceState.MATCHING, Instant.parse("2026-01-15T00:00:00Z"));
// => registeredAt: 2026-01-15; now: 2026-01-20 = 5 days later → within deadline
var r2 = autoDisputeIfOverdue(recentInv, Instant.parse("2026-01-20T00:00:00Z"));
System.out.println(r2.invoice().state() + " / " + r2.reason());
// => Output: MATCHING / null (within 30-day deadline)Key Takeaway: Time-based guards require an injectable now parameter to be testable — production uses the real clock; tests pass a fixed instant for reproducibility.
Why It Matters: Without injectable time, testing timeout logic requires manipulating system clocks or sleeping for 30 days. Injecting now makes it a pure function: pass a past date, verify auto-dispute fires; pass a recent date, verify it does not. This is the Clock port from the hexagonal architecture port list — the same testability principle applied to FSMs.
Example 43: Building an Invoice FSM Runner
A complete FSM runner encapsulates the transition table, guards, and entry actions in a single reusable class.
import java.util.*;
import java.util.function.*;
// => InvoiceFSMRunner: self-contained runner bundling table + guards + entry actions
// => Java class (not record): mutable guard/action registries registered at construction
public class InvoiceFSMRunner {
// => Inner enum: states scoped to the runner — avoids polluting the outer namespace
public enum State {
REGISTERED, MATCHING, MATCHED, DISPUTED, SCHEDULED_FOR_PAYMENT, PAID
// => PAID: no outgoing transitions — terminal state
}
// => Inner record: Invoice value object scoped to the runner
public record Invoice(String id, String poId, double supplierAmount, State state) {}
// => record: immutable — each apply() returns a new Invoice, never mutates input
// => Guard registry: event name → BiPredicate(Invoice, context)
// => BiPredicate: receives invoice + external data; returns true = guard passes
private final Map<String, BiPredicate<Invoice, Map<String, Object>>> guards =
new HashMap<>();
// => Entry action registry: state → Consumer<Invoice> run after entering that state
private final Map<State, Consumer<Invoice>> entryActions = new EnumMap<>(State.class);
// => Static transition table: immutable, shared across all runner instances
private static final Map<State, Map<String, State>> TABLE;
static {
// => Static initialiser: runs once at class load — thread-safe by JVM guarantee
TABLE = new EnumMap<>(State.class);
TABLE.put(State.REGISTERED, Map.of("start_match", State.MATCHING));
TABLE.put(State.MATCHING, Map.of(
"match_ok", State.MATCHED, // => Guard must pass for this to fire
"match_fail", State.DISPUTED)); // => Unguarded fallback
TABLE.put(State.DISPUTED, Map.of("resubmit", State.MATCHING));
TABLE.put(State.MATCHED, Map.of("schedule", State.SCHEDULED_FOR_PAYMENT));
TABLE.put(State.SCHEDULED_FOR_PAYMENT, Map.of("pay", State.PAID));
// => PAID: no entry — terminal state, Optional.empty() on any event
}
public InvoiceFSMRunner() {
// => Register match guard: match_ok only fires if tolerance check passes
guards.put("match_ok", (inv, ctx) -> {
double expected = (double) ctx.getOrDefault("expected", 0.0);
double tolerance = (double) ctx.getOrDefault("tolerance", 0.02);
// => expected/tolerance injected from caller context — not hardcoded in guard
if (expected == 0) return false;
// => Zero expected amount: guard cannot pass — reject immediately
return Math.abs(inv.supplierAmount() - expected) / expected <= tolerance;
// => Relative delta within tolerance → guard passes; otherwise → fails
});
// => Entry action: finance notified when invoice enters MATCHED
entryActions.put(State.MATCHED, inv ->
System.out.println("Notify finance: invoice " + inv.id() + " matched"));
// => In production: send email/webhook/event to AP team
// => Entry action: supplier notified when invoice enters DISPUTED
entryActions.put(State.DISPUTED, inv ->
System.out.println("Notify supplier: invoice " + inv.id() + " disputed"));
// => In production: send EDI/email to supplier with dispute details
}
// => apply: table lookup → guard check → entry action → return new Invoice
// => Optional.empty(): event not valid from current state, or guard failed
public Optional<Invoice> apply(Invoice inv, String event, Map<String, Object> ctx) {
State nextState = TABLE.getOrDefault(inv.state(), Map.of()).get(event);
// => TABLE lookup: null if state is terminal or event not registered
if (nextState == null) return Optional.empty();
// => No transition: FSM rejected the event — caller handles empty Optional
BiPredicate<Invoice, Map<String, Object>> guard = guards.get(event);
if (guard != null && !guard.test(inv, ctx)) return Optional.empty();
// => Guard found and failed: transition rejected (not a protocol error)
Invoice next = new Invoice(inv.id(), inv.poId(), inv.supplierAmount(), nextState);
// => Immutable update: new Invoice with the guard-determined next state
Consumer<Invoice> action = entryActions.get(nextState);
if (action != null) action.accept(next);
// => Fire entry action for new state; null-safe: not all states have actions
return Optional.of(next);
// => Successful transition: return updated invoice wrapped in Optional
}
}
// => Demonstrate: match_ok with passing guard → MATCHED + finance notification
InvoiceFSMRunner runner = new InvoiceFSMRunner();
var inv = new InvoiceFSMRunner.Invoice("inv_j01", "po_j01", 508.0,
InvoiceFSMRunner.State.MATCHING);
// => inv in MATCHING: ready to evaluate the three-way match
var matched = runner.apply(inv, "match_ok",
Map.of("expected", 500.0, "tolerance", 0.02));
// => 508 vs 500 = 1.6% delta ≤ 2% → guard passes
// => Output: Notify finance: invoice inv_j01 matched
matched.ifPresent(i -> System.out.println(i.state())); // => Output: MATCHED
var rejected = runner.apply(inv, "match_ok",
Map.of("expected", 600.0, "tolerance", 0.02));
// => 508 vs 600 = 15.3% delta > 2% → guard fails → Optional.empty()
System.out.println(rejected.isEmpty()); // => Output: true (guard rejected)Key Takeaway: A FSM runner class bundles table + guards + entry actions into a single reusable component — the same runner pattern works for any state machine in the system.
Why It Matters: Once you have a generic FSM runner, adding a new state machine (e.g., for Payment) is a matter of supplying a different table, guards, and actions — no new runner infrastructure. The runner is a framework in miniature, purpose-built for your domain's needs without the overhead of a general-purpose library.
Example 44: Coverage Snapshot — PO + Invoice Machine States
A tabular view of all states across both machines helps confirm the tutorial covers the full domain specification.
import java.util.EnumSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
// => MachineCoverage: immutable value object capturing a machine's state inventory
// => Java record: structural equality — two coverages with same data are interchangeable
public record MachineCoverage(
String machineName, // => "PurchaseOrder" or "Invoice"
List<String> states, // => All valid state names in declaration order
Set<String> terminalStates, // => States with no outgoing transitions
Set<String> offRamps // => Cancellation/dispute states available from most points
) {
// => stateCount: computed property — no need to store separately
public int stateCount() { return states.size(); }
// => terminalCount: derived from the set size
public int terminalCount() { return terminalStates.size(); }
// => offRampCount: derived from the set size
public int offRampCount() { return offRamps.size(); }
}
// => Domain coverage snapshot: one entry per FSM, expressed as data (not code)
// => List.of: immutable list — prevents accidental mutation of the snapshot
public static final List<MachineCoverage> DOMAIN_COVERAGE = List.of(
new MachineCoverage(
"PurchaseOrder",
List.of("Draft", "AwaitingApproval", "Approved", "Issued",
"Acknowledged", "PartiallyReceived", "Received",
"Invoiced", "Paid", "Closed", "Cancelled", "Disputed"),
// => 12 states: 10 happy-path + 2 off-ramps
Set.of("Closed", "Cancelled"), // => No outgoing transitions from these
Set.of("Cancelled", "Disputed") // => Available from most pre-Paid states
),
new MachineCoverage(
"Invoice",
List.of("Registered", "Matching", "Matched",
"Disputed", "ScheduledForPayment", "Paid"),
// => 6 states: linear lifecycle with one off-ramp
Set.of("Paid"), // => Terminal: payment disbursed — no further transitions
Set.of("Disputed") // => Off-ramp: match failed — supplier must resubmit
)
);
// => printCoverageReport: generates a per-machine summary from the snapshot data
// => Pure function: no state modification — reads from immutable DOMAIN_COVERAGE
public static void printCoverageReport() {
int totalStates = 0;
for (MachineCoverage mc : DOMAIN_COVERAGE) {
System.out.printf("%s: %d states, %d terminal, %d off-ramp(s)%n",
mc.machineName(), mc.stateCount(),
mc.terminalCount(), mc.offRampCount());
// => One line per machine: matches audit report format
totalStates += mc.stateCount();
}
System.out.println("Total domain states covered: " + totalStates);
// => Grand total: useful for tracking FSM growth over time in PRs
}
printCoverageReport();
// => Output:
// => PurchaseOrder: 12 states, 2 terminal, 2 off-ramp(s)
// => Invoice: 6 states, 1 terminal, 1 off-ramp(s)
// => Total domain states covered: 18Key Takeaway: Maintaining a coverage snapshot as code — not a separate document — ensures the diagram, the code, and the coverage report always refer to the same machine definition.
Why It Matters: In a long-lived system, the state machine grows. A coverage snapshot generated from the same transition table as the production code will always be accurate. A manually maintained spreadsheet will drift. The investment in code-generated coverage reporting pays dividends when an auditor asks "what states does your invoice lifecycle have and how do you test each one?"
Protocol Patterns (Examples 45-50)
Example 45: Idempotent Transitions
In distributed systems, events can be delivered more than once. An idempotent transition handler returns the same result for the same event applied to the same state, whether called once or ten times.
import java.util.Map;
import java.util.Optional;
// => idempotentTransition: safe handler for at-least-once event delivery
// => Returns Ok(currentInvoice) if already in the target state (idempotent success)
// => Returns Ok(newInvoice) if valid forward transition
// => Returns Err if genuinely invalid (not an idempotent case)
public static Result<Invoice> idempotentTransition(Invoice inv, String event) {
// => Step 1: attempt normal table-driven transition
InvoiceState next = INVOICE_TRANSITIONS
.getOrDefault(inv.state(), Map.of())
.get(event);
// => next: null if state is terminal or event not valid from current state
if (next != null) {
return new Result.Ok<>(new Invoice(
inv.id(), inv.poId(), inv.supplierAmount(), next));
// => Normal forward transition: advance to the next state
}
// => Step 2: no forward transition found — check for idempotent already-applied case
// => Scan all states to find which state would have been the *source* for this event
for (Map.Entry<InvoiceState, Map<String, InvoiceState>> entry
: INVOICE_TRANSITIONS.entrySet()) {
InvoiceState candidateTarget = entry.getValue().get(event);
// => candidateTarget: the state this event leads TO from this source state
if (inv.state() == candidateTarget) {
return new Result.Ok<>(inv);
// => Current state IS the target of this event — event already applied
// => Returning unchanged invoice makes duplicate delivery safe (idempotent)
}
}
// => Step 3: not in target state and no valid transition — genuine protocol error
return new Result.Err<>(
"Invalid transition: " + inv.state() + " --" + event + "-->");
// => Genuinely invalid: not idempotent, not a known forward transition
}
// => Demonstrate: first delivery — REGISTERED → MATCHING
var inv = new Invoice("inv_idem", "po_idem", 500.0, InvoiceState.REGISTERED);
var r1 = idempotentTransition(inv, "start_match");
// => First delivery: normal forward transition
Invoice matchingInv = r1 instanceof Result.Ok<Invoice> ok1 ? ok1.value() : inv;
System.out.println(matchingInv.state()); // => Output: MATCHING
// => Simulate second delivery of the same event (duplicate — Kafka at-least-once)
var r2 = idempotentTransition(matchingInv, "start_match");
// => MATCHING is the target of start_match from REGISTERED
// => Idempotent: current state IS the target — return unchanged
System.out.println(r2 instanceof Result.Ok ? "idempotent ok" : "error");
// => Output: idempotent ok
if (r2 instanceof Result.Ok<Invoice> ok2)
System.out.println(ok2.value().state()); // => Output: MATCHING (unchanged)Key Takeaway: Idempotent FSM handlers make duplicate event delivery safe — at-least-once delivery semantics (common in Kafka) do not corrupt state.
Why It Matters: Kafka, SQS, and most message brokers guarantee at-least-once delivery. Without idempotency, a duplicate start_match event would attempt a Matching → ??? transition and produce an error log — or worse, corrupt state if the error is swallowed. Idempotent handlers make the system robust to the delivery semantics of the infrastructure without special-case code per transition.
Example 46: Event Versioning — Migrating FSM State
When the state machine evolves, stored state values might use old names. A migration function maps old state names to new ones.
import java.util.Map;
import java.util.Set;
// => Migration table: maps deprecated v1 state names to their v2 canonical equivalents
// => Map.of: immutable — migration rules are configuration, not mutable runtime state
public static final Map<String, InvoiceState> STATE_MIGRATIONS = Map.of(
"InProgress", InvoiceState.MATCHING,
// => v1 used "InProgress"; v2 uses MATCHING for clarity in the event alphabet
"Approved", InvoiceState.MATCHED,
// => v1 "Approved" was ambiguous with PO approval; MATCHED is domain-specific
"Rejected", InvoiceState.DISPUTED,
// => v1 "Rejected" lacked nuance; DISPUTED implies supplier can correct and resubmit
"ReadyToPay", InvoiceState.SCHEDULED_FOR_PAYMENT
// => v1 short name; v2 uses the full canonical name matching the state model
);
// => Current valid state names: used to detect genuinely unknown states
// => Set.of: O(1) membership check; immutable — reflects the current FSM specification
public static final Set<String> CURRENT_STATE_NAMES = Set.of(
"REGISTERED", "MATCHING", "MATCHED", "DISPUTED", "SCHEDULED_FOR_PAYMENT", "PAID"
);
// => migrateInvoiceState: translates raw persistence string to current InvoiceState
// => Called at the repository boundary — the FSM never sees raw strings
public static InvoiceState migrateInvoiceState(String rawState) {
// => Step 1: check migration table for deprecated names
InvoiceState migrated = STATE_MIGRATIONS.get(rawState);
if (migrated != null) {
System.out.println("Migrated invoice state: '" + rawState +
"' -> " + migrated);
// => Log migration: provides audit trail for post-migration verification
return migrated;
// => Return canonical state — FSM proceeds with current names only
}
// => Step 2: validate that the raw state is a known current name
if (!CURRENT_STATE_NAMES.contains(rawState)) {
throw new IllegalArgumentException(
"Unknown invoice state: '" + rawState + "' — check migration table");
// => Unknown state: fail loudly — silent corruption is worse than a loud failure
}
return InvoiceState.valueOf(rawState);
// => Already a current state name: parse directly — no migration needed
}
// => Demonstrate: v1 name migration
System.out.println(migrateInvoiceState("InProgress"));
// => Output: Migrated invoice state: 'InProgress' -> MATCHING
// => Output: MATCHING
// => Demonstrate: current name passes through unchanged
System.out.println(migrateInvoiceState("MATCHED"));
// => Output: MATCHED (no migration: already current)
// => Unknown name would throw:
// => migrateInvoiceState("OldBroken"); → IllegalArgumentExceptionKey Takeaway: State migration functions isolate the versioning concern from the FSM logic — the machine always works with current state names; the migration layer translates at the persistence boundary.
Why It Matters: In a production system with years of data, state names change as the domain understanding matures. Without a migration layer, old state names in the database corrupt new FSM logic. With it, the FSM always sees canonical state names and the migration is tested independently of the machine.
Example 47: Read-Only State Queries
FSM state often drives UI rendering. Pure query functions over the state model are preferable to imperative conditional blocks in the UI layer.
import java.util.EnumMap;
import java.util.Map;
import java.util.Set;
// => UI query functions: derived directly from the FSM transition table
// => Pure static methods — no side effects, safe to call from any rendering layer
// => availableEvents: returns the set of valid event names from a given invoice state
// => Reads directly from the transition table — table IS the source of truth for UI
public static Set<String> availableEvents(InvoiceState state) {
return INVOICE_TRANSITIONS.getOrDefault(state, Map.of()).keySet();
// => getOrDefault: terminal states (PAID) have no entry — empty Map — empty Set
// => Set<String>: callers iterate for button labels or API menu items
}
// => requiresUserAction: true if the state requires supplier intervention
// => Only DISPUTED demands external human input; all others are system-driven
public static boolean requiresUserAction(InvoiceState state) {
return state == InvoiceState.DISPUTED;
// => Single enum comparison: O(1) — no collection needed for a one-state predicate
// => Extend to Set.of(DISPUTED, REGISTERED) if registration also needs human action
}
// => stateDisplayLabel: human-readable label for each FSM state
// => EnumMap keyed by state: O(1) lookup; all states must have an entry
public static final Map<InvoiceState, String> STATE_LABELS =
new EnumMap<>(InvoiceState.class) {{
put(InvoiceState.REGISTERED, "Invoice Received");
// => Supplier submitted; system not yet started matching
put(InvoiceState.MATCHING, "Under Review (Three-Way Match)");
// => Automated match in progress — no user action required
put(InvoiceState.MATCHED, "Approved for Payment");
// => Match passed within tolerance; finance may schedule payment
put(InvoiceState.DISPUTED, "Action Required — Please Review");
// => Match failed; supplier must correct amounts and resubmit
put(InvoiceState.SCHEDULED_FOR_PAYMENT, "Payment Scheduled");
// => Finance queued disbursement — no further action needed
put(InvoiceState.PAID, "Paid");
// => Terminal state — payment confirmed by bank
}};
// => Look up label; fallback to raw enum name for future states
public static String stateDisplayLabel(InvoiceState state) {
return STATE_LABELS.getOrDefault(state, state.name());
// => getOrDefault: any state added before the map is updated gets its raw name
// => Prevents NullPointerException while still surfacing the missing mapping
}
// => Demonstrate all three query functions
System.out.println(availableEvents(InvoiceState.MATCHING));
// => Output: [match_ok, match_fail] (both valid from MATCHING)
System.out.println(availableEvents(InvoiceState.PAID));
// => Output: [] (terminal: PAID has no entry in INVOICE_TRANSITIONS)
System.out.println(requiresUserAction(InvoiceState.DISPUTED));
// => Output: true (supplier must correct and resubmit)
System.out.println(requiresUserAction(InvoiceState.MATCHING));
// => Output: false (automated match process — system handles)
System.out.println(stateDisplayLabel(InvoiceState.DISPUTED));
// => Output: Action Required — Please ReviewKey Takeaway: Query functions derived from the FSM model keep the UI layer honest — available actions come from the transition table, not from a separate (potentially stale) UI configuration.
Why It Matters: When the UI independently decides which buttons to show, it can diverge from the FSM's actual valid transitions. The supplier portal might show a "Resubmit" button on a Matched invoice, leading to a confusing 400 error when clicked. Deriving UI state from the FSM model ensures the UI is always consistent with the backend.
Example 48: Two-Machine Sequence Diagram
A sequence diagram showing how the PO FSM and Invoice FSM coordinate across services.
sequenceDiagram
participant Buyer as Buyer/Finance
participant PO as PurchaseOrder FSM
participant Bus as Event Bus
participant Inv as Invoice FSM
participant Supplier as Supplier
Buyer->>PO: issue (Approved → Issued)
PO->>Bus: PurchaseOrderIssued
Bus->>Supplier: EDI/email notification
Supplier->>PO: acknowledge (Issued → Acknowledged)
Supplier->>Inv: submit invoice (Registered)
Inv->>Bus: InvoiceRegistered
Bus->>Inv: start_match
Inv->>Inv: three-way match guard
Inv->>Bus: InvoiceMatched
Bus->>PO: invoice_matched (Received → Invoiced)
Bus->>Buyer: notify payment scheduled
The diagram above shows the choreography. The code below shows how each language models the event flow programmatically.
import java.util.List;
import java.util.ArrayList;
// => SequenceStep: an immutable record representing one interaction in the sequence
// => Java record: value semantics — two steps with same fields are equal
public record SequenceStep(
String from, // => Sender participant (e.g. "Buyer", "PO FSM")
String to, // => Receiver participant
String message // => Event or action description
) {}
// => p2pSequence: ordered list of every step in the PO+Invoice coordination flow
// => Static factory builds the sequence as pure data — no execution, no side effects
public static List<SequenceStep> p2pSequence() {
var steps = new ArrayList<SequenceStep>();
// => Each add() appends one protocol step in causal order
steps.add(new SequenceStep("Buyer", "PO FSM", "issue (Approved → Issued)"));
// => Finance approves and issues the PO to the supplier
steps.add(new SequenceStep("PO FSM", "Event Bus", "PurchaseOrderIssued"));
// => PO FSM emits domain event — purchasing context publishes to bus
steps.add(new SequenceStep("Event Bus", "Supplier", "EDI/email notification"));
// => Supplier receives order confirmation via preferred channel
steps.add(new SequenceStep("Supplier", "PO FSM", "acknowledge (Issued → Acknowledged)"));
// => Supplier confirms receipt — PO enters Acknowledged state
steps.add(new SequenceStep("Supplier", "Invoice FSM", "submit invoice (Registered)"));
// => Supplier submits invoice — Invoice FSM enters Registered state
steps.add(new SequenceStep("Invoice FSM", "Event Bus", "InvoiceRegistered"));
// => Invoice FSM publishes InvoiceRegistered domain event
steps.add(new SequenceStep("Event Bus", "Invoice FSM", "start_match"));
// => Matching worker subscribes and fires start_match event to Invoice FSM
steps.add(new SequenceStep("Invoice FSM", "Invoice FSM", "three-way match guard"));
// => Internal self-transition: guard evaluates invoice vs GRN vs PO amounts
steps.add(new SequenceStep("Invoice FSM", "Event Bus", "InvoiceMatched"));
// => Guard passes: Invoice FSM publishes InvoiceMatched domain event
steps.add(new SequenceStep("Event Bus", "PO FSM", "invoice_matched (Received → Invoiced)"));
// => PO FSM subscribes to InvoiceMatched — advances to Invoiced
steps.add(new SequenceStep("Event Bus", "Buyer", "notify payment scheduled"));
// => Finance notified: payment scheduled for upcoming disbursement run
return List.copyOf(steps);
// => List.copyOf: unmodifiable snapshot — callers cannot mutate the sequence
}
// => Print sequence as a simple protocol trace
p2pSequence().forEach(s ->
System.out.printf("%-14s -> %-14s : %s%n", s.from(), s.to(), s.message()));
// => Output:
// => Buyer -> PO FSM : issue (Approved → Issued)
// => PO FSM -> Event Bus : PurchaseOrderIssued
// => Event Bus -> Supplier : EDI/email notification
// => Supplier -> PO FSM : acknowledge (Issued → Acknowledged)
// => Supplier -> Invoice FSM : submit invoice (Registered)
// => Invoice FSM -> Event Bus : InvoiceRegistered
// => Event Bus -> Invoice FSM : start_match
// => Invoice FSM -> Invoice FSM : three-way match guard
// => Invoice FSM -> Event Bus : InvoiceMatched
// => Event Bus -> PO FSM : invoice_matched (Received → Invoiced)
// => Event Bus -> Buyer : notify payment scheduledKey Takeaway: The sequence diagram confirms that neither FSM calls the other directly — they coordinate exclusively via domain events on the event bus, enabling independent deployment and testing.
Why It Matters: The sequence shows the asynchronous handoff between the two FSMs — neither machine directly calls the other. They communicate via domain events on the bus. This decoupling is the reason both machines can be tested and deployed independently.
Example 49: Encoding SLA in FSM Metadata
Each state can carry a maximum dwell time — the SLA for how long the workflow can stay in that state before escalation.
import java.time.Duration;
import java.time.Instant;
import java.util.EnumMap;
import java.util.Map;
import java.util.Optional;
// => EscalationAction: enum of the three escalation strategies
// => Enum: type-safe — callers cannot pass arbitrary strings as actions
public enum EscalationAction {
WARN, // => Warn the responsible team; do not auto-transition
AUTO_DISPUTE, // => Automatically move invoice to Disputed state
ESCALATE_MANAGER // => Escalate to procurement manager for manual review
}
// => StateSLA: immutable SLA definition for one FSM state
// => Java record: value semantics — SLA config is data, not mutable state
public record StateSLA(
long maxHours, // => Maximum hours invoice may dwell in this state
String escalateTo, // => Recipient role for breach notifications
EscalationAction action // => What the background worker does on breach
) {}
// => INVOICE_SLAS: per-state SLA registry — only states with SLAs have entries
// => EnumMap: O(1) lookup by InvoiceState key; null for states without SLA
public static final Map<InvoiceState, StateSLA> INVOICE_SLAS =
new EnumMap<>(InvoiceState.class) {{
put(InvoiceState.MATCHING, new StateSLA(
24L, "accounts_payable", EscalationAction.WARN));
// => MATCHING: 24h SLA — match should complete within one business day
// => WARN: alert AP team but do not force a state change automatically
put(InvoiceState.DISPUTED, new StateSLA(
168L, "procurement_manager", EscalationAction.ESCALATE_MANAGER));
// => DISPUTED: 168h SLA (7 days) — supplier must resubmit within one week
// => ESCALATE_MANAGER: involve manager if supplier fails to act in time
// => REGISTERED, MATCHED, SCHEDULED_FOR_PAYMENT, PAID: no SLA entry
// => System-driven states resolve quickly or have no contractual deadline
}};
// => SlaCheckResult: bundles all information a background worker needs
// => Java record: immutable result — no risk of partial mutation between checks
public record SlaCheckResult(
boolean breached, // => True if elapsed time exceeds maxHours
Optional<StateSLA> sla, // => SLA definition if one exists for this state
double hoursElapsed // => Actual hours since state entry (for audit log)
) {}
// => checkSLA: pure function — injectable Instant enables deterministic testing
// => No system clock calls: background worker passes its own clock, tests pass a fixed Instant
public static SlaCheckResult checkSLA(
Invoice invoice, Instant enteredAt, Instant now) {
StateSLA sla = INVOICE_SLAS.get(invoice.state());
// => O(1) EnumMap lookup — null if no SLA defined for this state
if (sla == null) {
return new SlaCheckResult(false, Optional.empty(), 0.0);
// => No SLA defined: cannot breach — return safe default
}
double hoursElapsed = Duration.between(enteredAt, now).toMinutes() / 60.0;
// => Fractional hours: Duration.toMinutes() gives precise sub-hour resolution
// => Divide by 60.0 (double) for fractional hours — avoids integer truncation
boolean breached = hoursElapsed > sla.maxHours();
// => Strict greater-than: exactly at the limit is not yet a breach
return new SlaCheckResult(breached, Optional.of(sla), hoursElapsed);
// => Breached or not: always return full result for downstream decision-making
}
// => Demonstrate: invoice entered MATCHING 72 hours ago (3 days) — 24h SLA breached
var matchingInv = new Invoice("inv_sla01", "po_sla01", 500.0, InvoiceState.MATCHING);
var enteredAt = Instant.parse("2026-01-10T09:00:00Z");
// => enteredAt: injection point — tests pass a fixed Instant, not the system clock
var now = Instant.parse("2026-01-13T09:00:00Z");
// => now: 72 hours after enteredAt — well past the 24h MATCHING SLA
SlaCheckResult result = checkSLA(matchingInv, enteredAt, now);
System.out.println(result.breached()); // => Output: true (72h > 24h SLA)
System.out.println(result.hoursElapsed()); // => Output: 72.0
result.sla().ifPresent(s -> {
System.out.println(s.action()); // => Output: WARN
System.out.println(s.escalateTo()); // => Output: accounts_payable
});Key Takeaway: SLA metadata attached to FSM states turns the state machine into a living workflow monitor — not just a state tracker, but a time-aware process manager.
Why It Matters: Procurement workflows have regulatory and contractual SLAs. An invoice sitting in Disputed for 30 days might trigger a late-payment penalty. Encoding SLA in FSM metadata and checking it from a background job (payments-worker) means SLA breaches surface automatically — no manual monitoring required.
Example 50: Summary — FSM as System Architecture
The final intermediate example synthesises what the Invoice and PO machines together encode: the complete P2P business protocol as a formal system.
import java.util.List;
import java.util.Map;
// => MachineSummary: immutable record describing one FSM in the P2P system
// => Java record: value semantics — the summary IS the specification of the machine
public record MachineSummary(
String name, // => Machine name (e.g. "PurchaseOrder", "Invoice")
String context, // => Bounded context (e.g. "purchasing", "invoicing")
int stateCount, // => Total number of FSM states
int eventCount, // => Total number of events in the event alphabet
List<String> terminal, // => States with no outgoing transitions
String protocol // => Human-readable actor sequence for this machine
) {}
// => P2P_SYSTEM: compile-time snapshot of the two-machine P2P FSM system
// => Static constants: both machines declared once; consumed by reporting and audit tools
public static final MachineSummary PO_MACHINE = new MachineSummary(
"PurchaseOrder",
"purchasing", // => Bounded context owning the PO lifecycle
12, // => Draft → Closed (12 states including PartiallyReceived)
10, // => submit, approve, reject, issue, acknowledge, ... pay, close
List.of("Closed", "Cancelled"),
// => Two terminal states: happy-path Closed and off-ramp Cancelled
"Buyer → Manager → Finance → Supplier → Finance → System"
// => Protocol: role sequence for the PO approval and fulfilment flow
);
public static final MachineSummary INVOICE_MACHINE = new MachineSummary(
"Invoice",
"invoicing", // => Bounded context owning the invoice lifecycle
6, // => Registered → Paid (6 states)
6, // => start_match, match_ok, match_fail, resubmit, schedule, pay
List.of("Paid"),
// => One terminal state: Paid — no further transitions after disbursement
"Supplier → System (three-way match) → Finance → Bank"
// => Protocol: role sequence for the invoice submission and payment flow
);
public static final String P2P_COORDINATION =
"Domain events via Event Bus (InvoiceMatched → PO invoice_matched)";
// => Coordination: the only coupling between bounded contexts is the event schema
// => Print system summary — the same data used for audit reports and diagrams
public static void printP2PSystem() {
System.out.println("P2P FSM System:");
for (MachineSummary m : List.of(PO_MACHINE, INVOICE_MACHINE)) {
System.out.printf(" %s [%s]: %d states, %d events%n",
m.name(), m.context(), m.stateCount(), m.eventCount());
// => Per-machine line: machine name, bounded context, state and event counts
System.out.printf(" Protocol: %s%n", m.protocol());
// => Protocol: human-readable actor sequence for documentation
System.out.printf(" Terminal: %s%n", m.terminal());
// => Terminal states: states with no outgoing transitions
}
System.out.println(" Coordination: " + P2P_COORDINATION);
// => Coordination: how the two machines talk to each other
}
printP2PSystem();
// => Output:
// => PurchaseOrder [purchasing]: 12 states, 10 events
// => Protocol: Buyer → Manager → Finance → Supplier → Finance → System
// => Terminal: [Closed, Cancelled]
// => Invoice [invoicing]: 6 states, 6 events
// => Protocol: Supplier → System (three-way match) → Finance → Bank
// => Terminal: [Paid]
// => Coordination: Domain events via Event Bus (InvoiceMatched → PO invoice_matched)Key Takeaway: Two FSMs coordinating via domain events implement a complete business process without a general-purpose workflow engine — the FSMs are the protocol, the event bus is the channel.
Why It Matters: Many teams reach for a BPMN workflow engine (Camunda, Activiti) to coordinate multi-step business processes. For a well-understood, bounded protocol like P2P, a composed FSM system is simpler: no DSL to learn, no engine to operate, no XML to version. The FSM is code — it is tested, typed, and deployed with the application. When the protocol needs to change, a PR with a one-line transition table update is the change — no workflow migration needed.
Last updated January 30, 2026