Skip to content
AyoKoding

Beginner

This beginner tutorial introduces Finite State Machine fundamentals through 25 annotated code examples grounded in the PurchaseOrder aggregate from the procurement-platform-be backend. Rust is the canonical language here — its typestate pattern encodes the legal transition graph directly into the type system so the compiler rejects illegal transitions at build time, not at runtime. Go uses looplab/fsm (v1.0.3, Apache 2.0, 3.4k stars) as a declarative runtime alternative.

Domain scope note: The beginner PurchaseOrder covers the core approval-issuance lifecycle (Draft → Submitted → ApprovalPending → Issued → Received → Paid | Cancelled | Disputed). States from the full domain spec — PartiallyReceived and multi-machine coordination — are intentionally deferred to intermediate and advanced levels.

Canonical sources: Ana Hoverbear — Pretty State Machine Patterns in Rust; Will Crichton — Type-Driven API Design in Rust; Jim Blandy, Jason Orendorff, and Leonora F. S. Tindall — Programming Rust, 3rd ed. (O'Reilly, 2024); looplab/fsm (v1.0.3, Apache 2.0).

States as Types (Examples 1–4)

Example 1: States as Distinct Structs

A PurchaseOrder begins as a Draft, moves through approval, gets issued to a supplier, and eventually closes or is cancelled. In Rust, each state is a distinct struct type. The compiler rejects code that uses the wrong struct — there is no String or integer that can accidentally represent an unlisted state.

stateDiagram-v2
    [*] --> Draft
    Draft --> Submitted: submit
    Submitted --> ApprovalPending: request_approval
    ApprovalPending --> Issued: approve
    ApprovalPending --> Cancelled: reject
    Issued --> Received: receive
    Received --> Paid: pay
    Draft --> Cancelled: cancel
    Issued --> Disputed: dispute
 
    classDef draft fill:#0173B2,stroke:#000,color:#fff
    classDef pending fill:#DE8F05,stroke:#000,color:#000
    classDef active fill:#029E73,stroke:#000,color:#fff
    classDef terminal fill:#CA9161,stroke:#000,color:#fff
    classDef disputed fill:#CC78BC,stroke:#000,color:#fff
 
    class Draft draft
    class Submitted,ApprovalPending pending
    class Issued,Received active
    class Paid,Cancelled terminal
    class Disputed disputed
// => Rust: each FSM state is a zero-size marker struct — a "typestate"
// => Zero-size structs consume no memory at runtime; they exist only at compile time
// => The compiler uses these types to enforce which transitions are legal
 
// => Draft: PO has been created but not yet submitted for approval
pub struct Draft;
 
// => Submitted: PO is submitted; supplier selection underway
pub struct Submitted;
 
// => ApprovalPending: formal approval request sent to budget-holder
pub struct ApprovalPending;
 
// => Issued: PO transmitted to supplier; lines are now immutable
pub struct Issued;
 
// => Received: goods received; awaiting payment
pub struct Received;
 
// => Paid: PO lifecycle complete — terminal state
pub struct Paid;
 
// => Cancelled: PO abandoned before payment — terminal state
pub struct Cancelled;
 
// => Disputed: discrepancy detected between PO and delivery — resolution required
pub struct Disputed;
 
// => fn main shows that each state is a concrete type, not a string or integer
fn main() {
    let _d: Draft = Draft;
    // => Draft is a real type; the compiler rejects `let _d: Draft = Submitted`
    let _s: Submitted = Submitted;
    // => Each state is physically distinct — no accidental confusion
    println!("States compile — each is a distinct type");
    // => Output: States compile — each is a distinct type
}

Key Takeaway: Rust typestate turns every FSM state into a compile-time type, making illegal state values impossible to express; Go represents state as a string field checked at runtime.

Why It Matters: The difference between compile-time and runtime checking is significant in production systems. A Rust typestate mistake surfaces as a build error during development; a Go invalid-state mistake surfaces as a runtime error that requires test coverage or runtime monitoring to detect. For procurement workflows where an incorrect state might trigger a payment or a goods receipt before the PO is approved, compile-time enforcement removes an entire class of defects before the code ships.


Example 2: The Generic PurchaseOrder<S> Struct

The state marker type becomes a type parameter on the aggregate struct. PurchaseOrder<Draft> and PurchaseOrder<Issued> are different types from the compiler's perspective, so you cannot pass an Issued PO where a Draft PO is expected — even though both share the same field layout.

// => PhantomData<S> carries the state type at compile time without allocating memory
// => The `S` type parameter makes PurchaseOrder<Draft> and PurchaseOrder<Issued> distinct
use std::marker::PhantomData;
 
pub struct PurchaseOrder<S> {
    pub id: String,
    // => Immutable identifier; every PO gets a UUID on creation
    pub total_amount: f64,
    // => Monetary total in USD; approval guards compare against thresholds
    _state: PhantomData<S>,
    // => Zero-memory marker — `S` is only visible to the type checker
}
 
impl<S> PurchaseOrder<S> {
    // => Private constructor — callers must use the public factory functions
    // => This ensures every PO starts in Draft and cannot be created in Issued
    fn new(id: impl Into<String>, total_amount: f64) -> Self {
        PurchaseOrder {
            id: id.into(),
            total_amount,
            _state: PhantomData,
            // => PhantomData requires no argument; the type carries the marker
        }
    }
}
 
impl PurchaseOrder<Draft> {
    // => The only way to get a PurchaseOrder<Draft> — enforces FSM start invariant
    pub fn create(id: impl Into<String>, total_amount: f64) -> Self {
        Self::new(id, total_amount)
        // => All POs begin in Draft — no other entry point exists
    }
}
 
fn main() {
    let po: PurchaseOrder<Draft> = PurchaseOrder::create("po_001", 1500.0);
    // => Type annotation is optional; inferred by the compiler from `create`
    println!("id={} amount={}", po.id, po.total_amount);
    // => Output: id=po_001 amount=1500
    // => let bad: PurchaseOrder<Issued> = PurchaseOrder::create("x", 0.0);
    // => Compile error: expected PurchaseOrder<Issued>, found PurchaseOrder<Draft>
}

Key Takeaway: PurchaseOrder<S> binds the state marker to the aggregate struct via a type parameter, making different states produce incompatible types; Go embeds the FSM machine as a field carrying the string state at runtime.

Why It Matters: The PhantomData<S> field has zero memory cost — it exists only during compilation. You get the safety of a fully typed state machine with no runtime overhead. In contrast, Go's approach is flexible and serialisable out of the box, because the state is just a string that can be persisted to a database or transmitted over the wire without any special marshalling code.


Example 3: Terminal States and the IsTerminal Helper

Terminal states have no outgoing transitions. Encoding termination as a type-level property in Rust means the compiler refuses to call any transition method on a PurchaseOrder<Paid> or PurchaseOrder<Cancelled> — because no transition methods are implemented on those concrete types.

// => No impl block for Paid or Cancelled means no transition methods exist on them
// => Calling any method not defined for the concrete type is a compile error
 
use std::marker::PhantomData;
 
// => Reuse the generic struct from Example 2 — only the state marker differs
pub struct PurchaseOrder<S> {
    pub id: String,
    pub total_amount: f64,
    _state: PhantomData<S>,
}
 
// => Paid has no impl block with transition methods — it is a dead end by construction
// => Cancelled similarly has no transitions defined
 
// => Runtime helper: useful in contexts where the state is erased (e.g., serialisation)
// => Takes the state name as a string — this is the only place strings appear
pub fn is_terminal(state_name: &str) -> bool {
    // => Compare against the two terminal state names
    matches!(state_name, "Paid" | "Cancelled")
    // => `matches!` is idiomatic Rust pattern matching in a boolean context
}
 
fn main() {
    println!("{}", is_terminal("Draft"));     // => Output: false
    println!("{}", is_terminal("Paid"));      // => Output: true
    println!("{}", is_terminal("Cancelled")); // => Output: true
    // => At the typestate level, PurchaseOrder<Paid> cannot call `.submit()` etc.
    // => because no such method exists — the compiler enforces termination structurally
}

Key Takeaway: Rust encodes terminal states structurally — no methods, no transitions, no way to proceed; Go encodes them as a runtime set checked in helper functions.

Why It Matters: Terminal states are a common source of bugs in workflow engines. A payment service that accidentally re-processes a Paid PO because it missed a terminal check can double-pay a supplier. Rust prevents this at compile time. Go requires the developer to call isTerminal at the right places — a discipline requirement rather than a structural guarantee. Both approaches work; the difference is where the failure mode surfaces.


Example 4: Displaying the Current State

Serialising and logging the current state is essential for audit trails. Rust implements the Display trait to convert typestate structs to human-readable strings. Go's current state is already a string from f.Current().

// => std::fmt::Display converts a value to a human-readable string
// => Implementing Display for each state struct enables `format!("{}", state_name)`
use std::fmt;
 
// => State structs declared here (shared across examples in a real module)
pub struct Draft;
pub struct Submitted;
pub struct ApprovalPending;
pub struct Issued;
pub struct Received;
pub struct Paid;
pub struct Cancelled;
pub struct Disputed;
 
// => Macro to reduce boilerplate: implement Display for each state struct
macro_rules! impl_display_state {
    ($($t:ty => $name:expr),*) => {
        $(
            impl fmt::Display for $t {
                fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
                    write!(f, $name)
                    // => Write the literal state name string to the formatter
                }
            }
        )*
    };
}
 
impl_display_state!(
    Draft         => "Draft",
    Submitted     => "Submitted",
    ApprovalPending => "ApprovalPending",
    Issued        => "Issued",
    Received      => "Received",
    Paid          => "Paid",
    Cancelled     => "Cancelled",
    Disputed      => "Disputed"
);
 
fn main() {
    println!("{}", Draft);          // => Output: Draft
    println!("{}", Issued);         // => Output: Issued
    println!("{}", Cancelled);      // => Output: Cancelled
    // => Format strings use Display automatically; no `.to_string()` call needed
    let label = format!("State: {}", ApprovalPending);
    println!("{}", label);          // => Output: State: ApprovalPending
}

Key Takeaway: Implementing Display on Rust typestate structs gives you a consistent serialised name for logging and persistence; Go's state string is already the serialised name by definition.

Why It Matters: Audit logs and event stores need a stable string representation of state. In Rust, the Display implementation is the single source of truth for that string — if you rename the struct, the compiler forces you to update Display too, so log entries remain consistent. In Go, state names are defined once as constants and reused everywhere, achieving the same stability with a simpler mechanism.


The Transition Table (Examples 5–8)

Example 5: Consuming self in Rust Transitions

In Rust, a transition method takes ownership of self and returns a new value of a different state type. After the call, the original binding is moved out and can never be used again — the compiler enforces this with a borrow check error.

// => Transitions consume `self` — the Draft is destroyed, a Submitted is born
// => This is the core typestate invariant: one state in, one state out
use std::marker::PhantomData;
 
pub struct PurchaseOrder<S> {
    pub id: String,
    pub total_amount: f64,
    _state: PhantomData<S>,
}
 
impl<S> PurchaseOrder<S> {
    fn transition<T>(self) -> PurchaseOrder<T> {
        // => Move all fields into the new state type — no copying of heap data
        PurchaseOrder { id: self.id, total_amount: self.total_amount, _state: PhantomData }
    }
}
 
impl PurchaseOrder<Draft> {
    // => `self` (not `&self`) means the Draft PO is consumed — caller loses it
    pub fn submit(self) -> PurchaseOrder<Submitted> {
        self.transition()
        // => Returns PurchaseOrder<Submitted>; the Draft no longer exists
    }
}
 
impl PurchaseOrder<Submitted> {
    pub fn request_approval(self) -> PurchaseOrder<ApprovalPending> {
        self.transition()
        // => Submitted is consumed; caller receives ApprovalPending
    }
}
 
fn main() {
    let draft = PurchaseOrder::<Draft>::create("po_003", 2000.0);
    let submitted = draft.submit();
    // => `draft` is moved here — using it after this line is a compile error
    // => error[E0382]: borrow of moved value: `draft`
    println!("state=Submitted id={}", submitted.id);
    // => Output: state=Submitted id=po_003
}

Key Takeaway: Rust's ownership model makes invalid state re-use a compile error; Go's Event method returns an error value that callers must check to detect invalid transitions.

Why It Matters: The Rust approach catches misuse at compile time — a developer who accidentally re-uses a Draft PO after calling submit gets a build error. In Go, the same mistake would silently succeed until f.Current() returned an unexpected state. This is why the Rust typestate idiom is described as the strongest compile-time FSM guarantee available in any language taught on this site.


Example 6: The Full Transition Table

Defining all transitions in one place makes the full state machine inspectable and prevents transitions from being scattered across unrelated files.

stateDiagram-v2
    [*] --> Draft
    Draft --> Submitted: submit
    Submitted --> ApprovalPending: request_approval
    ApprovalPending --> Issued: approve
    ApprovalPending --> Cancelled: reject
    Issued --> Received: receive
    Issued --> Disputed: dispute
    Issued --> Cancelled: cancel
    Received --> Paid: pay
    Received --> Disputed: dispute
    Draft --> Cancelled: cancel
    Submitted --> Cancelled: cancel
 
    classDef start fill:#0173B2,stroke:#000,color:#fff
    classDef pending fill:#DE8F05,stroke:#000,color:#000
    classDef active fill:#029E73,stroke:#000,color:#fff
    classDef terminal fill:#CA9161,stroke:#000,color:#fff
    classDef disputed fill:#CC78BC,stroke:#000,color:#fff
 
    class Draft start
    class Submitted,ApprovalPending pending
    class Issued,Received active
    class Paid,Cancelled terminal
    class Disputed disputed
// => Rust: the full transition table is expressed as impl blocks, one per state
// => Each impl block contains only the methods legal from that state
// => Trying to call `.approve()` on a Draft is a compile error — no such method
 
use std::marker::PhantomData;
 
pub struct PurchaseOrder<S> {
    pub id: String,
    pub total_amount: f64,
    _state: PhantomData<S>,
}
 
impl<S> PurchaseOrder<S> {
    fn transition<T>(self) -> PurchaseOrder<T> {
        PurchaseOrder { id: self.id, total_amount: self.total_amount, _state: PhantomData }
    }
}
 
// => Draft: submit or cancel
impl PurchaseOrder<Draft> {
    pub fn submit(self) -> PurchaseOrder<Submitted>       { self.transition() }
    pub fn cancel(self) -> PurchaseOrder<Cancelled>       { self.transition() }
}
 
// => Submitted: request_approval or cancel
impl PurchaseOrder<Submitted> {
    pub fn request_approval(self) -> PurchaseOrder<ApprovalPending> { self.transition() }
    pub fn cancel(self) -> PurchaseOrder<Cancelled>                  { self.transition() }
}
 
// => ApprovalPending: approve (→ Issued) or reject (→ Cancelled)
impl PurchaseOrder<ApprovalPending> {
    pub fn approve(self) -> PurchaseOrder<Issued>         { self.transition() }
    pub fn reject(self)  -> PurchaseOrder<Cancelled>      { self.transition() }
}
 
// => Issued: receive, cancel, or dispute
impl PurchaseOrder<Issued> {
    pub fn receive(self)  -> PurchaseOrder<Received>      { self.transition() }
    pub fn cancel(self)   -> PurchaseOrder<Cancelled>     { self.transition() }
    pub fn dispute(self)  -> PurchaseOrder<Disputed>      { self.transition() }
}
 
// => Received: pay or dispute — only two exits
impl PurchaseOrder<Received> {
    pub fn pay(self)     -> PurchaseOrder<Paid>           { self.transition() }
    pub fn dispute(self) -> PurchaseOrder<Disputed>       { self.transition() }
}
 
// => Paid and Cancelled have no impl blocks — they are genuinely terminal
 
fn main() {
    let po = PurchaseOrder::<Draft>::create("po_004", 3000.0);
    let po = po.submit();           // => Draft → Submitted; original `po` moved
    let po = po.request_approval(); // => Submitted → ApprovalPending
    let po = po.approve();          // => ApprovalPending → Issued
    let po = po.receive();          // => Issued → Received
    let _paid = po.pay();           // => Received → Paid (terminal)
    println!("Final state reached: Paid");
    // => Output: Final state reached: Paid
}

Key Takeaway: Rust expresses the transition table as per-state impl blocks whose method signatures are the table; Go expresses it as a flat fsm.Events slice that looplab/fsm validates at runtime.

Why It Matters: Having all transitions defined in one place — whether Rust impl blocks or a Go fsm.Events slice — makes the state machine auditable. You can read the full lifecycle without tracing through conditional logic scattered across a codebase. This property is essential during security audits and regulatory compliance reviews where auditors need to verify that no payment state can be reached without proper approval.


Example 7: Invalid Transition Rejection

Both Rust and Go must reject transitions that are not in the table. Rust does it structurally (no method exists); Go returns an InvalidEventError.

// => Rust: calling a method that does not exist on the concrete type is a compile error
// => No runtime check is needed — the type system forbids the call entirely
 
// => Assuming Draft, Submitted, Issued structs and PurchaseOrder<S> from previous examples
 
fn demonstrate_invalid_transition() {
    let draft = PurchaseOrder::<Draft>::create("po_005", 500.0);
    // => Draft has `.submit()` and `.cancel()` — no `.approve()` method exists
 
    // => Uncommenting the line below causes a compile-time error:
    // let _ = draft.approve();
    // => error[E0599]: no method named `approve` found for struct
    // =>   `PurchaseOrder<Draft>` in the current scope
 
    let submitted = draft.submit();
    // => Submitted has `.request_approval()` — no `.pay()` method exists
 
    // => Uncommenting the line below causes a compile-time error:
    // let _ = submitted.pay();
    // => error[E0599]: no method named `pay` found for struct
    // =>   `PurchaseOrder<Submitted>` in the current scope
 
    println!("Invalid transitions rejected at compile time — no runtime error possible");
    // => Output: Invalid transitions rejected at compile time — no runtime error possible
    let _ = submitted; // => suppress unused-variable warning
}
 
fn main() {
    demonstrate_invalid_transition();
}

Key Takeaway: Rust turns invalid transitions into compile errors; Go turns them into typed errors returned from f.Event that callers must handle explicitly.

Why It Matters: Both patterns enforce the transition table — neither silently ignores an invalid transition. The practical difference is the feedback loop: Rust developers learn about invalid transitions in their editor before running the code; Go developers learn during testing when the error is returned. For teams that invest heavily in unit tests, Go's approach is pragmatic. For teams that want maximum safety with minimal test surface, Rust's compile-time enforcement is compelling.


Example 8: Inspecting Permitted Transitions

Knowing which transitions are available from the current state is useful for building UI menus, generating audit reports, and debugging.

// => Rust: permitted transitions are expressed as method availability on the type
// => A trait listing all transitions can be used to query them at runtime
use std::collections::HashSet;
 
// => Trait: implemented by each state struct to advertise its outgoing transitions
pub trait HasTransitions {
    fn available_events(&self) -> HashSet<&'static str>;
    // => Returns the set of event names callable in this state
}
 
impl HasTransitions for Draft {
    fn available_events(&self) -> HashSet<&'static str> {
        ["submit", "cancel"].iter().copied().collect()
        // => Only two events are valid from Draft
    }
}
 
impl HasTransitions for Submitted {
    fn available_events(&self) -> HashSet<&'static str> {
        ["request_approval", "cancel"].iter().copied().collect()
    }
}
 
impl HasTransitions for ApprovalPending {
    fn available_events(&self) -> HashSet<&'static str> {
        ["approve", "reject"].iter().copied().collect()
        // => Approval decisions are the only valid events here
    }
}
 
impl HasTransitions for Issued {
    fn available_events(&self) -> HashSet<&'static str> {
        ["receive", "cancel", "dispute"].iter().copied().collect()
    }
}
 
impl HasTransitions for Paid {
    fn available_events(&self) -> HashSet<&'static str> {
        HashSet::new()
        // => Empty set — terminal state allows no further transitions
    }
}
 
fn main() {
    let draft = Draft;
    println!("Draft events: {:?}", draft.available_events());
    // => Output: {"submit", "cancel"}  (order may vary — HashSet is unordered)
    let paid = Paid;
    println!("Paid events: {:?}", paid.available_events());
    // => Output: {}  (empty — no transitions from terminal state)
}

Key Takeaway: Rust exposes available transitions through a trait returning a HashSet; Go exposes them through AvailableTransitions() built into looplab/fsm.

Why It Matters: Querying permitted transitions at runtime is used in two important scenarios: (1) generating action menus in approval UIs so buyers only see the buttons relevant to their current workflow step, and (2) building audit reports that describe what actions were available at each point in the PO lifecycle. The looplab/fsm AvailableTransitions method provides this for free; Rust requires a trait but makes the contract explicit.


Guards (Examples 9–12)

Example 9: A Guard Returns Result

A guard is a condition that must be true before a transition may proceed. In Rust, the transition method returns Result<PurchaseOrder<NextState>, DomainError> so the caller is forced to handle the failure case at the type level.

// => Guard on the approve transition: total_amount must be within the approver's threshold
// => Returns Err(DomainError) if the guard fails; the PO stays in ApprovalPending
 
// => Domain error type — using an enum to distinguish different failure reasons
#[derive(Debug)]
pub enum DomainError {
    AmountExceedsApprovalLimit { amount: f64, limit: f64 },
    // => The error carries both the amount and the limit for diagnostic messages
}
 
// => The approval threshold for the beginner-level guard
const APPROVAL_LIMIT: f64 = 10_000.0;
 
impl PurchaseOrder<ApprovalPending> {
    // => Returns Result: Ok(Issued) on success, Err(DomainError) on guard failure
    // => `self` is consumed either way — on Err the caller must decide what to do next
    pub fn approve(self) -> Result<PurchaseOrder<Issued>, DomainError> {
        if self.total_amount > APPROVAL_LIMIT {
            // => Guard fails: return the error without consuming the PO into Issued
            return Err(DomainError::AmountExceedsApprovalLimit {
                amount: self.total_amount,
                limit: APPROVAL_LIMIT,
            });
        }
        Ok(self.transition())
        // => Guard passes: PO moves to Issued state
    }
}
 
fn main() {
    // => Happy path: amount within limit
    let po_ok = PurchaseOrder::<ApprovalPending>::new("po_006", 5000.0);
    match po_ok.approve() {
        Ok(issued) => println!("Approved — id={}", issued.id),
        // => Output: Approved — id=po_006
        Err(e)     => println!("Error: {:?}", e),
    }
 
    // => Guard failure: amount exceeds limit
    let po_fail = PurchaseOrder::<ApprovalPending>::new("po_007", 15000.0);
    match po_fail.approve() {
        Ok(_)      => println!("Should not reach here"),
        Err(e)     => println!("Rejected: {:?}", e),
        // => Output: Rejected: AmountExceedsApprovalLimit { amount: 15000.0, limit: 10000.0 }
    }
}

Key Takeaway: Rust embeds the guard in the return type — Result forces callers to handle failure; Go uses before_<event> callbacks that cancel the transition and return the error through the f.Event return value.

Why It Matters: Guards encode business rules such as approval thresholds, budget checks, and supplier verification at the transition boundary. Putting guards inside the transition method (Rust) or the before_<event> callback (Go) means the check cannot be bypassed by calling the wrong function. A developer who forgets to check the guard still gets the correct behavior — the machine refuses to advance.


Example 10: Multiple Guards with Early Return

Some transitions require multiple conditions. Rust uses ? for early return; Go's callback can check conditions sequentially and cancel on the first failure.

// => Multiple guards on the approve transition using the `?` operator
// => Each guard returns early with an Err if it fails — remaining guards are skipped
 
#[derive(Debug)]
pub enum DomainError {
    AmountExceedsLimit { amount: f64, limit: f64 },
    // => Amount guard failure
    MissingSupplierCode,
    // => Supplier must be assigned before approval
}
 
pub struct ApprovalPendingData {
    pub id: String,
    pub total_amount: f64,
    pub supplier_code: Option<String>,
    // => None means supplier not yet assigned — blocks approval
}
 
// => impl block with multiple guards
impl PurchaseOrder<ApprovalPending> {
    pub fn approve(self) -> Result<PurchaseOrder<Issued>, DomainError>
    where
        ApprovalPending: HasData<ApprovalPendingData>,
    {
        // => Guard 1: supplier must be assigned
        if self.data.supplier_code.is_none() {
            return Err(DomainError::MissingSupplierCode);
            // => Early return — remaining guards are not evaluated
        }
        // => Guard 2: amount must be within threshold
        if self.data.total_amount > 10_000.0 {
            return Err(DomainError::AmountExceedsLimit {
                amount: self.data.total_amount,
                limit: 10_000.0,
            });
            // => `?` could replace this block if the guards returned Result
        }
        Ok(self.transition())
        // => Both guards passed — transition proceeds
    }
}
 
fn main() {
    // => Simplified demonstration of the guard logic without full generics
    let amount = 5000.0_f64;
    let supplier_code: Option<String> = Some("SUP_001".to_string());
 
    let result: Result<(), DomainError> = (|| {
        if supplier_code.is_none() { return Err(DomainError::MissingSupplierCode); }
        if amount > 10_000.0 { return Err(DomainError::AmountExceedsLimit { amount, limit: 10_000.0 }); }
        Ok(())
    })();
    println!("{:?}", result); // => Output: Ok(())
 
    let no_supplier: Option<String> = None;
    let result2: Result<(), DomainError> = (|| {
        if no_supplier.is_none() { return Err(DomainError::MissingSupplierCode); }
        Ok(())
    })();
    println!("{:?}", result2); // => Output: Err(MissingSupplierCode)
}

Key Takeaway: Both Rust and Go support ordered guard evaluation with early exit on first failure; Rust uses the ? operator and return types while Go uses explicit return inside the callback.

Why It Matters: Real procurement rules require multiple conditions before a transition. A PO might need a supplier, a budget code, a digital signature, and a line-item count before it can be approved. Ordering the guards by cheapness (check the quick, cheap conditions first) and exiting early on failure minimises unnecessary work and makes error messages more actionable for the buyer.


Example 11: Guard on Amount Threshold with Error Reporting

The approval guard failure message should tell the approver exactly what is wrong and what is required — generic error strings make debugging and UI error display harder.

// => Structured error types carry context for diagnostic messages and UI rendering
use std::fmt;
 
// => Error enum with structured variants — each variant carries its own fields
#[derive(Debug)]
pub enum ApprovalError {
    AmountExceedsLimit { amount: f64, limit: f64 },
    // => Carries the actual amount and the configured limit
    MissingSupplierCode { po_id: String },
    // => Carries the PO ID so the error is traceable in logs
}
 
// => Implement Display for human-readable error messages
impl fmt::Display for ApprovalError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            ApprovalError::AmountExceedsLimit { amount, limit } =>
                write!(f, "PO amount {:.2} exceeds approval limit {:.2}", amount, limit),
            // => Exact amount and limit in the message — approver knows what to change
            ApprovalError::MissingSupplierCode { po_id } =>
                write!(f, "PO {} cannot be approved without a supplier code", po_id),
            // => PO ID in the message — traceable in the audit log
        }
    }
}
 
fn check_approval_guard(po_id: &str, amount: f64, limit: f64) -> Result<(), ApprovalError> {
    if amount > limit {
        return Err(ApprovalError::AmountExceedsLimit { amount, limit });
        // => Returns structured error — caller can pattern-match to render it
    }
    Ok(())
}
 
fn main() {
    let err = check_approval_guard("po_010", 15_000.0, 10_000.0);
    match err {
        Ok(()) => println!("Guard passed"),
        Err(e) => {
            println!("Guard failed: {}", e);
            // => Output: Guard failed: PO amount 15000.00 exceeds approval limit 10000.00
            if let ApprovalError::AmountExceedsLimit { amount, limit } = e {
                println!("Need to reduce by {:.2}", amount - limit);
                // => Output: Need to reduce by 5000.00
            }
        }
    }
}

Key Takeaway: Structured error types carry the context needed for diagnostic messages, UI rendering, and programmatic handling — generic strings require parsing and lose fidelity.

Why It Matters: In a procurement platform, the approver sees the error in a web UI. If the error is "invalid amount", the approver cannot act without opening the PO details. If the error is "PO amount 15000.00 exceeds approval limit 10000.00", the approver immediately knows the issue. Structured errors also enable middleware to translate error codes into localised UI messages without string matching.


Example 12: Guard Testing

Guards are pure business logic that should be tested independently of the state machine infrastructure. A guard test verifies both the pass and fail branches.

// => Rust: unit tests in the same module using the built-in `#[test]` attribute
// => `cargo test` discovers and runs all functions annotated with `#[test]`
 
#[cfg(test)]
// => cfg(test) ensures test code is compiled only when running tests, not in production
mod tests {
    use super::*;
 
    // => Test 1: amount below limit — guard should pass
    #[test]
    fn approve_guard_passes_when_amount_within_limit() {
        let result = check_approval_guard("po_011", 5_000.0, 10_000.0);
        // => Guard passes — result should be Ok(())
        assert!(result.is_ok(), "Expected Ok, got {:?}", result);
        // => assert! panics with the message if the condition is false
    }
 
    // => Test 2: amount exactly at limit — guard should pass (boundary)
    #[test]
    fn approve_guard_passes_at_exact_limit() {
        let result = check_approval_guard("po_012", 10_000.0, 10_000.0);
        assert!(result.is_ok(), "Exact limit should be allowed");
        // => The guard uses `>` not `>=` — at the limit is still valid
    }
 
    // => Test 3: amount exceeds limit — guard should fail with structured error
    #[test]
    fn approve_guard_fails_when_amount_exceeds_limit() {
        let result = check_approval_guard("po_013", 15_000.0, 10_000.0);
        assert!(result.is_err(), "Expected Err, got {:?}", result);
        // => Verify the error carries the correct amounts
        if let Err(ApprovalError::AmountExceedsLimit { amount, limit }) = result {
            assert_eq!(amount, 15_000.0);
            assert_eq!(limit, 10_000.0);
        }
    }
}
 
fn main() {
    println!("Run with: cargo test");
    // => `cargo test` compiles and runs the #[test] functions automatically
}

Key Takeaway: Guard tests verify both pass and fail branches — and the fail branch should assert that the error carries the correct structured data, not just that some error occurred.

Why It Matters: An approval guard that always returns nil would pass a test that only checks err != nil. Testing the error value forces the guard implementation to carry enough context for diagnosis. The boundary test (amount exactly at limit) catches off-by-one errors in the > vs >= comparison that are common sources of disagreement between technical teams and finance stakeholders.


Line Items and Totals (Examples 13–16)

Example 13: Adding Line Items to a Draft PO

A PurchaseOrder in Draft state has mutable line items. Line items encode what is being ordered, from whom, at what price. Once the PO transitions out of Draft, line items must become immutable — the supplier has been told what to deliver.

// => LineItem represents a single procured item in the PO
// => Clone and Debug derived for easy copying and debugging
#[derive(Debug, Clone)]
pub struct LineItem {
    pub sku: String,
    // => Stock Keeping Unit — supplier's product identifier
    pub description: String,
    // => Human-readable product name
    pub quantity: u32,
    // => Ordered quantity; must be > 0
    pub unit_price: f64,
    // => Price per unit in USD
}
 
impl LineItem {
    pub fn new(sku: &str, description: &str, quantity: u32, unit_price: f64) -> Self {
        LineItem {
            sku: sku.to_string(),
            description: description.to_string(),
            quantity,
            unit_price,
        }
        // => Constructor; validation could check quantity > 0 and unit_price >= 0
    }
 
    pub fn line_total(&self) -> f64 {
        // => `&self` — this is a query method, does not consume the item
        self.quantity as f64 * self.unit_price
        // => Converts u32 quantity to f64 for multiplication
    }
}
 
// => PurchaseOrder<Draft> carries a mutable Vec<LineItem>
pub struct DraftPO {
    pub id: String,
    pub items: Vec<LineItem>,
    // => Vec is only on Draft — other state structs do not carry a mutable items field
}
 
impl DraftPO {
    pub fn add_item(&mut self, item: LineItem) {
        // => &mut self — method mutates the DraftPO; cannot be called on Issued or Paid
        self.items.push(item);
    }
 
    pub fn total(&self) -> f64 {
        self.items.iter().map(|i| i.line_total()).sum()
        // => Iterator chaining: map each item to its total, then sum all totals
    }
}
 
fn main() {
    let mut draft = DraftPO { id: "po_014".to_string(), items: vec![] };
    draft.add_item(LineItem::new("SKU-001", "Industrial Pump", 2, 3_500.0));
    draft.add_item(LineItem::new("SKU-002", "Filter Cartridge", 10, 85.0));
    println!("Total: {:.2}", draft.total());
    // => Output: Total: 7850.00  (2 * 3500 + 10 * 85)
}

Key Takeaway: In Rust, the add_item method exists only on DraftPO — the compiler prevents calling it on IssuedPO; in Go, the method exists on all states and a runtime guard rejects it when the state is wrong.

Why It Matters: Immutability of line items after PO issuance is a core procurement control. A PO that has been transmitted to a supplier and then has its items changed creates discrepancy between what the supplier is delivering and what the buyer expects to receive. Encoding this constraint in the type system (Rust) makes it impossible to write the bug; encoding it as a runtime guard (Go) makes it detectable in tests.


Example 14: Computing the PO Total

The total is derived from line items on every call rather than cached — this is the functional approach where computed values are functions of their inputs, not stored state that can drift out of sync.

// => total() is a pure computed property — no mutable field that could desync
// => Iterator::sum() produces the aggregate without intermediate allocations
impl DraftPO {
    pub fn total(&self) -> f64 {
        // => &self means this is a read-only query — no state change
        self.items
            .iter()
            // => iter() produces references — does not take ownership
            .map(|item| item.line_total())
            // => Map each LineItem to its monetary total (f64)
            .sum()
            // => sum() folds all f64 values into one using addition
    }
 
    pub fn item_count(&self) -> usize {
        self.items.len()
        // => usize is the natural index type in Rust — always non-negative
    }
 
    pub fn average_unit_price(&self) -> Option<f64> {
        // => Option<f64> because the average is undefined when there are no items
        if self.items.is_empty() {
            return None;
            // => Return None rather than panic or return 0.0 — explicit absence
        }
        let total_units: f64 = self.items.iter().map(|i| i.quantity as f64).sum();
        Some(self.total() / total_units)
        // => Some wraps the computed average — caller must handle the Option
    }
}
 
fn main() {
    let mut draft = DraftPO { id: "po_015".to_string(), items: vec![] };
    draft.add_item(LineItem::new("SKU-001", "Pump", 2, 3_500.0));
    draft.add_item(LineItem::new("SKU-002", "Filter", 10, 85.0));
    println!("Total: {:.2}", draft.total());
    // => Output: Total: 7850.00
    println!("Items: {}", draft.item_count());
    // => Output: Items: 2
    println!("Avg unit price: {:?}", draft.average_unit_price());
    // => Output: Avg unit price: Some(654.1666...)  (7850 / 12 units total)
}

Key Takeaway: Computing totals on each call from line items rather than caching them eliminates desynchronisation bugs where the stored total drifts from the items after a modification.

Why It Matters: Caching derived values in mutable fields is a common source of financial bugs. A total field that is set in addItem but not updated in removeItem will report incorrect amounts to approvers. The functional approach — compute total from items every time — trades a small CPU cost for perfect correctness. For procurement amounts, correctness is non-negotiable; a PO with the wrong total might bypass an approval threshold check.


Example 15: Preventing Item Mutation After Submission

Once a PO transitions to Submitted, its items must be frozen. Rust enforces this structurally; Go enforces it at runtime.

// => Rust: only DraftPO has add_item(); SubmittedPO does not have the method
// => After calling .submit(), the DraftPO is consumed — its mutation methods vanish
 
pub struct DraftPO {
    pub id: String,
    pub items: Vec<LineItem>,
}
 
pub struct SubmittedPO {
    pub id: String,
    pub items: Vec<LineItem>,
    // => Same items, now frozen — no add_item method on SubmittedPO
}
 
impl DraftPO {
    pub fn add_item(&mut self, item: LineItem) {
        // => Only available on DraftPO — not on SubmittedPO or any later state
        self.items.push(item);
    }
 
    // => submit() consumes self and returns SubmittedPO — items are frozen
    pub fn submit(self) -> SubmittedPO {
        SubmittedPO { id: self.id, items: self.items }
        // => Items move into SubmittedPO — the Vec itself is not copied
    }
}
 
impl SubmittedPO {
    pub fn total(&self) -> f64 {
        self.items.iter().map(|i| i.line_total()).sum()
        // => total() is still available — it's a read-only query
    }
    // => No add_item() — calling draft.add_item() after submit() is a compile error
}
 
fn main() {
    let mut draft = DraftPO { id: "po_016".to_string(), items: vec![] };
    draft.add_item(LineItem::new("SKU-001", "Pump", 1, 5_000.0));
    let submitted = draft.submit();
    // => draft is moved — draft.add_item() is now a compile error
    // => error[E0382]: borrow of moved value: `draft`
    println!("Submitted total: {:.2}", submitted.total());
    // => Output: Submitted total: 5000.00
}

Key Takeaway: Rust prevents mutation after submission by moving items into a new struct type that has no add_item method; Go prevents it at runtime via a state guard in the method.

Why It Matters: Preventing post-submission mutation of line items is a procurement audit control. If a buyer could change quantities after a PO has been submitted for approval, the approver might approve one set of quantities while the supplier receives a different set. Encoding this as a compile-time constraint (Rust) or a tested runtime guard (Go) are both acceptable patterns — the important thing is that the control exists at all.


Audit Trail and Event Log (Examples 17–21)

Example 16: Defining the Audit Event Type

Every state transition generates an audit event recording who triggered the transition, from which state, to which state, and at what time. This is the foundation of the immutable audit log.

// => chrono provides timezone-aware timestamps — required for audit trails
// => Add `chrono = { version = "0.4", features = ["serde"] }` to Cargo.toml
use chrono::{DateTime, Utc};
 
// => StateTransition records one edge in the state machine execution history
#[derive(Debug, Clone)]
pub struct StateTransition {
    pub from_state: String,
    // => Name of the source state (serialised by Display impl)
    pub to_state: String,
    // => Name of the destination state
    pub event: String,
    // => Name of the event that triggered the transition
    pub actor: String,
    // => Identity of the user or system that triggered the event
    pub occurred_at: DateTime<Utc>,
    // => UTC timestamp — stored in UTC, formatted locally for display
}
 
impl StateTransition {
    pub fn new(
        from: &str,
        to: &str,
        event: &str,
        actor: &str,
    ) -> Self {
        StateTransition {
            from_state: from.to_string(),
            to_state:   to.to_string(),
            event:      event.to_string(),
            actor:      actor.to_string(),
            occurred_at: Utc::now(),
            // => Utc::now() captures the current UTC instant
        }
    }
}
 
fn main() {
    let entry = StateTransition::new("Draft", "Submitted", "submit", "buyer@corp.com");
    println!("Transition: {} --[{}]--> {} by {} at {}",
        entry.from_state, entry.event, entry.to_state,
        entry.actor, entry.occurred_at.to_rfc3339()
    );
    // => Output: Transition: Draft --[submit]--> Submitted by buyer@corp.com at 2026-05-24T...
}

Key Takeaway: The audit event type carries exactly five fields — from, to, event, actor, and timestamp — which are sufficient to reconstruct the full execution history of any PO.

Why It Matters: Procurement regulations in many jurisdictions require an immutable audit trail showing every state change, who made it, and when. This log is also essential for debugging: when a PO arrives in Disputed state, the audit trail shows the sequence of transitions that led there, allowing the procurement team to identify whether the dispute was filed by the correct actor at the correct point in the lifecycle.


Example 17: Attaching an Audit Log to the PO

The audit log is a Vec<StateTransition> (Rust) or []StateTransition (Go) attached to the PO struct. Each transition appends a new entry to the log — entries are never modified or deleted.

// => PO with embedded audit log — audit_log is Vec<StateTransition> in the struct
use std::marker::PhantomData;
 
pub struct PurchaseOrder<S> {
    pub id: String,
    pub total_amount: f64,
    pub audit_log: Vec<StateTransition>,
    // => Owned Vec — the PO is the sole owner of its audit history
    _state: PhantomData<S>,
}
 
impl PurchaseOrder<Draft> {
    pub fn create_with_log(id: impl Into<String>, amount: f64, actor: &str) -> Self {
        let id_str = id.into();
        let entry = StateTransition::new("", "Draft", "create", actor);
        // => The creation event has no from_state — it is the genesis entry
        PurchaseOrder {
            id: id_str,
            total_amount: amount,
            audit_log: vec![entry],
            // => vec![entry] initialises the log with the creation event
            _state: PhantomData,
        }
    }
 
    pub fn submit_with_log(mut self, actor: &str) -> PurchaseOrder<Submitted> {
        // => mut self so we can push to audit_log before moving
        self.audit_log.push(StateTransition::new("Draft", "Submitted", "submit", actor));
        PurchaseOrder { id: self.id, total_amount: self.total_amount,
                        audit_log: self.audit_log, _state: PhantomData }
        // => Moves the audit_log into the new state — log is preserved across transitions
    }
}
 
fn main() {
    let po = PurchaseOrder::<Draft>::create_with_log("po_017", 2000.0, "system");
    let po = po.submit_with_log("buyer@corp.com");
    for entry in &po.audit_log {
        println!("{} → {} ({})", entry.from_state, entry.to_state, entry.actor);
    }
    // => Output: → Draft (system)
    // => Output: Draft → Submitted (buyer@corp.com)
}

Key Takeaway: The audit log is an append-only slice that grows with each transition; never modify or delete entries — immutability is the property that makes audit trails trustworthy.

Why It Matters: An audit log that can be modified after the fact provides no assurance. In regulated procurement environments, the audit trail must be tamper-evident. The append-only slice pattern (Rust Vec::push, Go append) makes it structurally clear that entries accumulate — no remove, update, or clear operation appears in the codebase.


Example 18: Querying the Audit Log

Common audit queries include finding when a specific event occurred, who approved a PO, and how many transitions a PO went through before reaching its current state.

// => Audit log queries are pure functions over &[StateTransition]
// => No mutation — queries read the log without changing it
 
pub fn find_event(log: &[StateTransition], event: &str) -> Option<&StateTransition> {
    log.iter().find(|entry| entry.event == event)
    // => Iterator::find returns the first match as Option<&T>
    // => Returns None if the event is not in the log — explicit absence
}
 
pub fn actor_for_event(log: &[StateTransition], event: &str) -> Option<&str> {
    find_event(log, event).map(|entry| entry.actor.as_str())
    // => Option::map transforms Some(entry) to Some(actor), propagating None
}
 
pub fn transition_count(log: &[StateTransition]) -> usize {
    log.len()
    // => Counting transitions is O(1) — Vec stores its length
}
 
fn main() {
    let log = vec![
        StateTransition::new("",        "Draft",     "create",  "system"),
        StateTransition::new("Draft",   "Submitted", "submit",  "buyer@corp.com"),
        StateTransition::new("Submitted","ApprovalPending","request_approval","buyer@corp.com"),
        StateTransition::new("ApprovalPending","Issued","approve","approver@corp.com"),
    ];
 
    println!("Approver: {:?}", actor_for_event(&log, "approve"));
    // => Output: Approver: Some("approver@corp.com")
    println!("Approve at: {:?}", find_event(&log, "approve").map(|e| &e.occurred_at));
    // => Output: Approve at: Some(2026-05-24T...)
    println!("Total transitions: {}", transition_count(&log));
    // => Output: Total transitions: 4
}

Key Takeaway: Audit log queries are pure functions over the log slice — they take a reference and return derived values without mutating the log.

Why It Matters: Procurement auditors need to answer questions like "who approved this PO?" and "was the dispute raised before or after the goods were received?" These queries must be efficient and correct. A pure function over the log is easy to unit-test and has no hidden dependencies on external state. The Option / nil return pattern makes the absence of an event explicit — if find_event(log, "approve") returns None, the code cannot accidentally treat None as a valid actor.


Example 19: Serialising the Audit Log with Serde

Persisting and transmitting the audit log requires serialisation. Rust uses serde with serde_json; Go uses the standard encoding/json package.

// => serde and serde_json provide serialisation to/from JSON
// => Add to Cargo.toml: serde = { version = "1", features = ["derive"] }
// =>                    serde_json = "1"
// =>                    chrono = { version = "0.4", features = ["serde"] }
use serde::{Deserialize, Serialize};
use chrono::{DateTime, Utc};
 
// => Derive Serialize and Deserialize — serde generates the impl at compile time
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StateTransition {
    pub from_state:   String,
    pub to_state:     String,
    pub event:        String,
    pub actor:        String,
    #[serde(with = "chrono::serde::ts_seconds")]
    // => ts_seconds serialises DateTime<Utc> as a Unix epoch integer
    // => This is compact and timezone-unambiguous
    pub occurred_at:  DateTime<Utc>,
}
 
fn main() {
    let log = vec![
        StateTransition {
            from_state:  "Draft".to_string(),
            to_state:    "Submitted".to_string(),
            event:       "submit".to_string(),
            actor:       "buyer@corp.com".to_string(),
            occurred_at: Utc::now(),
        },
    ];
 
    // => serde_json::to_string_pretty produces indented JSON — useful for logging
    let json = serde_json::to_string_pretty(&log).unwrap();
    println!("{}", json);
    // => Output:
    // => [
    // =>   {
    // =>     "from_state": "Draft",
    // =>     "to_state": "Submitted",
    // =>     "event": "submit",
    // =>     "actor": "buyer@corp.com",
    // =>     "occurred_at": 1748044800
    // =>   }
    // => ]
 
    // => Deserialise back — the roundtrip must be lossless
    let restored: Vec<StateTransition> = serde_json::from_str(&json).unwrap();
    println!("Restored entries: {}", restored.len());
    // => Output: Restored entries: 1
}

Key Takeaway: Deriving Serialize/Deserialize (Rust) or using json: struct tags (Go) produces JSON serialisation with minimal boilerplate — the audit log can be stored in a database or transmitted over the wire without hand-written conversion code.

Why It Matters: The audit log must survive process restarts, database migrations, and service deployments. A serialisable log can be stored in a jsonb column in PostgreSQL, replicated to an audit service, or streamed to a message queue. Using the same field names in Rust and Go (from_state, to_state) also enables interoperability if a mixed-language microservice architecture reads the same audit events.


Example 20: Testing the Audit Log

The audit log is business-critical data — it must be tested to verify that every transition appends exactly one entry with the correct fields.

// => Test that the audit log captures transitions correctly
// => Each test is self-contained — no shared fixtures or global state
 
#[cfg(test)]
mod tests {
    use super::*;
 
    // => Test: create produces one genesis entry
    #[test]
    fn create_appends_genesis_entry() {
        let po = PurchaseOrder::<Draft>::create_with_log("po_test_001", 1000.0, "system");
        assert_eq!(po.audit_log.len(), 1, "Expected exactly one genesis entry");
        // => One entry after creation — the genesis event
        let entry = &po.audit_log[0];
        assert_eq!(entry.event, "create");
        assert_eq!(entry.to_state, "Draft");
        assert_eq!(entry.actor, "system");
    }
 
    // => Test: submit appends a second entry with correct from/to
    #[test]
    fn submit_appends_transition_entry() {
        let po = PurchaseOrder::<Draft>::create_with_log("po_test_002", 1000.0, "system");
        let po = po.submit_with_log("buyer@corp.com");
        assert_eq!(po.audit_log.len(), 2, "Expected two entries after submit");
        // => Genesis entry + submit entry
        let entry = &po.audit_log[1];
        assert_eq!(entry.event,      "submit");
        assert_eq!(entry.from_state, "Draft");
        assert_eq!(entry.to_state,   "Submitted");
        assert_eq!(entry.actor,      "buyer@corp.com");
    }
}
 
fn main() {
    println!("Run with: cargo test");
}

Key Takeaway: Audit log tests verify count, sequence, and field values — not just that something was logged, but that the right thing was logged with the right data.

Why It Matters: An audit log that records transitions but uses the wrong field values provides false assurance. A test that checks only len(po.audit_log) == 2 would pass even if both entries had from_state = "". Testing the actual field values ensures the log is meaningful for compliance reporting and debugging.


Testing (Examples 22–25)

Example 21: Testing the Happy Path End-to-End

A happy-path test walks the PO through every valid transition from Draft to Paid and verifies the final state. This test documents the expected lifecycle and serves as executable specification.

// => End-to-end happy path test — verifies the full lifecycle compiles and runs
// => In Rust, if this test compiles, the transition sequence is valid by type
 
#[cfg(test)]
mod tests {
    use super::*;
 
    #[test]
    fn happy_path_draft_to_paid() {
        // => Each step creates a new variable with a new state type
        let draft = PurchaseOrder::<Draft>::create("po_e2e_001", 5_000.0);
        // => State: Draft
 
        let submitted = draft.submit();
        // => State: Submitted — draft is moved and cannot be reused
 
        let pending = submitted.request_approval();
        // => State: ApprovalPending
 
        let issued = pending.approve().expect("approval should succeed at 5000");
        // => State: Issued — approve() returns Result; .expect() unwraps or panics
 
        let received = issued.receive();
        // => State: Received
 
        let paid = received.pay();
        // => State: Paid — terminal state; no further methods defined
 
        // => The fact that this compiles proves the transition sequence is valid
        assert_eq!(paid.id, "po_e2e_001");
        // => Verify the ID survived all the state transitions
        assert_eq!(paid.total_amount, 5_000.0);
        // => Verify the amount survived all the state transitions
        println!("Happy path passed — PO reached Paid state");
    }
}
 
fn main() {
    println!("Run with: cargo test");
}

Key Takeaway: The happy-path test serves as executable documentation of the PO lifecycle — it breaks if any transition is removed from the state machine, alerting the team to a breaking change.

Why It Matters: The happy-path test is the most important test in the suite because it captures the primary business scenario. When a developer refactors the transition table and accidentally removes the receive event, the happy-path test fails immediately with a clear message about which step broke. Without it, the failure might only appear in end-to-end tests or in production when a goods-receipt event is rejected.


Example 22: Testing Invalid Transitions

Invalid transition tests verify that the state machine correctly rejects operations that are not permitted in the current state.

// => In Rust, invalid transitions are compile errors — they cannot be tested at runtime
// => We test that the type system prevents the call by showing the compiler rejects it
 
// => The following code would not compile — it documents the enforcement mechanism
// => #[test]
// => fn cannot_approve_from_draft() {
// =>     let draft = PurchaseOrder::<Draft>::create("po_inv_001", 1000.0);
// =>     let _ = draft.approve(); // error[E0599]: no method named `approve` on Draft
// => }
 
// => What we can test at runtime: guard failures that return Err
#[cfg(test)]
mod tests {
    use super::*;
 
    #[test]
    fn approve_fails_when_amount_exceeds_limit() {
        // => This transition is structurally valid (ApprovalPending → Issued)
        // => but the guard rejects it because the amount is too high
        let po = PurchaseOrder::<ApprovalPending>::new("po_inv_002", 50_000.0);
        let result = po.approve();
        assert!(result.is_err(), "Guard should have rejected high-amount PO");
        // => Verify that the error is the guard failure, not some other error
        if let Err(DomainError::AmountExceedsApprovalLimit { amount, limit }) = result {
            assert_eq!(amount, 50_000.0);
            assert_eq!(limit, 10_000.0);
        } else {
            panic!("Expected AmountExceedsApprovalLimit error");
        }
    }
}

Key Takeaway: Rust invalid-transition tests document that the compiler rejects the call; Go invalid-transition tests assert that f.Event returns InvalidEventError and that the state does not change on failure.

Why It Matters: Testing invalid transitions is as important as testing valid ones. Without these tests, a refactor that accidentally adds "pay" as a valid event from "submitted" would go undetected until a buyer attempted to pay an unapproved PO. The Go test also verifies that the state does not change on failure — this property is called transition atomicity and is critical for preventing partial state corruption.


Example 23: Testing State After a Guard Failure

When a guard fails, the PO must remain in its original state — the failed transition must not partially advance the machine.

// => Test that a guard failure leaves the PO in its original state
// => In Rust, the `self` is consumed in either branch — we verify the Err variant
 
#[cfg(test)]
mod tests {
    use super::*;
 
    #[test]
    fn guard_failure_does_not_advance_state() {
        let po = PurchaseOrder::<ApprovalPending>::new("po_gf_001", 20_000.0);
        // => total_amount of 20_000 exceeds the 10_000 limit
 
        let result = po.approve();
        // => `po` is consumed here — success returns Issued, failure returns Err
 
        match result {
            Ok(_) => panic!("Should have been rejected by the guard"),
            Err(DomainError::AmountExceedsApprovalLimit { amount, limit }) => {
                // => Verify the error carries the correct context
                assert_eq!(amount, 20_000.0, "Error should report the actual amount");
                assert_eq!(limit,  10_000.0, "Error should report the configured limit");
                // => The PO is gone (consumed) — caller must create a new one or
                // => reconstruct from the error if they need to retry
            }
            Err(e) => panic!("Unexpected error variant: {:?}", e),
        }
    }
}

Key Takeaway: A failed transition must leave the machine in exactly the state it was in before the attempt — partial state advancement on guard failure is a serious correctness bug.

Why It Matters: If a guard failure partially advanced the state, the PO would be in an inconsistent position — not in ApprovalPending (where it can still be rejected or cancelled) and not in Issued (where goods can be received). This would make the PO orphaned: stuck in a state with no valid transitions. The test for unchanged state after guard failure catches this class of bug before it reaches production.


Example 24: Integration Test — Full Lifecycle with Audit Log

The integration test combines all concepts from Examples 1–24 to verify the complete PO lifecycle from creation to payment, including the audit log entries at each step.

// => Integration test: full lifecycle with audit log verification
// => This is the closest Rust equivalent to an end-to-end test in a unit test file
 
#[cfg(test)]
mod integration_tests {
    use super::*;
 
    #[test]
    fn full_lifecycle_with_audit_log() {
        // => Step 1: Create Draft PO
        let po = PurchaseOrder::<Draft>::create_with_log("po_int_001", 7_500.0, "system");
        assert_eq!(po.audit_log.len(), 1);
        // => Genesis entry appended at creation
 
        // => Step 2: Submit
        let po = po.submit_with_log("buyer@corp.com");
        assert_eq!(po.audit_log.len(), 2);
        assert_eq!(po.audit_log[1].event, "submit");
        // => submit entry appended
 
        // => Step 3: Request approval
        let po = po.request_approval_with_log("buyer@corp.com");
        assert_eq!(po.audit_log.len(), 3);
 
        // => Step 4: Approve — guard passes at 7500 < 10000
        let po = po.approve_with_log("approver@corp.com")
            .expect("approval guard should pass for 7500");
        assert_eq!(po.audit_log.len(), 4);
        // => approve entry appended
 
        // => Step 5: Receive
        let po = po.receive_with_log("warehouse@corp.com");
        assert_eq!(po.audit_log.len(), 5);
 
        // => Step 6: Pay — terminal transition
        let po = po.pay_with_log("finance@corp.com");
        assert_eq!(po.audit_log.len(), 6);
        // => All six transitions recorded
 
        // => Verify actor tracking across the full lifecycle
        assert_eq!(actor_for_event(&po.audit_log, "approve"), Some("approver@corp.com"));
        assert_eq!(actor_for_event(&po.audit_log, "pay"),     Some("finance@corp.com"));
        assert_eq!(po.id, "po_int_001");
        assert_eq!(po.total_amount, 7_500.0);
 
        println!("Integration test passed: {} entries in audit log", po.audit_log.len());
        // => Output: Integration test passed: 6 entries in audit log
    }
}

Key Takeaway: The integration test is the capstone of the beginner tier — it exercises every concept from Examples 1–24 in a single coherent scenario and verifies that they compose correctly.

Why It Matters: Individual unit tests verify each component in isolation, but integration tests verify that the components work together. In procurement systems, the full lifecycle test catches regressions where a change to the approve transition inadvertently breaks the receive transition three steps later. The combination of compile-time enforcement (Rust) or typed runtime errors (Go) with a full-lifecycle integration test provides high confidence that the PO state machine behaves correctly under the expected business scenario.


Further Reading

Last updated May 23, 2026

Command Palette

Search for a command to run...