Skip to content
AyoKoding

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: DISPUTED

Key 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: true

Key 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: DISPUTED

Key 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 match

Key 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 INVOICED

Key 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 RECEIVED

Key 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% max

Key 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 MatchedScheduledForPaymentPaid 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: 18

Key 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"); → IllegalArgumentException

Key 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 Review

Key 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 scheduled

Key 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

Command Palette

Search for a command to run...