place_order — an order lifecycle¶
What it does¶
The canonical e-commerce order, from cart to delivery: a payment that may be declined and
retried, fulfilment (pick, pack, choose a carrier), shipping and delivery — plus cancelling
while the payment is pending, and the two unexpected failures a real order meets: the payment
gateway or the warehouse raising. run.py replays seven scenarios, each on a fresh order, and
prints every step:
=== payment retried, then paid ===
(start) -> Cart
PlaceOrder -> AwaitingPayment
PaymentDeclined -> AwaitingPayment
PaymentAuthorized -> Fulfilling.Picking
Picked -> Fulfilling.Packing
Packed -> Fulfilling.Express
Dispatched -> Shipped
Delivered -> Delivered
status=DONE outcome=success
The machine¶
stateDiagram-v2
[*] --> Cart
state "Cart" as Cart
Cart : on enter#58; on_cart
state "AwaitingPayment" as AwaitingPayment
AwaitingPayment : on enter#58; request_payment
state "Retrying" as Retrying
Retrying : on enter#58; on_retry
state "Paid" as Paid
Paid : on enter#58; capture_payment
state "Fulfilling<br/>on enter: start_fulfilment" as Fulfilling {
[*] --> Fulfilling_Picking
state "Picking" as Fulfilling_Picking
Fulfilling_Picking : on enter#58; pick
state "Packing" as Fulfilling_Packing
Fulfilling_Packing : on enter#58; pack
state "Express" as Fulfilling_Express
Fulfilling_Express : on enter#58; book_express
state "Standard" as Fulfilling_Standard
Fulfilling_Standard : on enter#58; book_standard
Fulfilling_Picking --> Fulfilling_Packing : Picked
state Fulfilling_Packing__choose_carrier <<choice>>
Fulfilling_Packing --> Fulfilling_Packing__choose_carrier : Packed
Fulfilling_Packing__choose_carrier --> Fulfilling_Express : choose_carrier=express
Fulfilling_Packing__choose_carrier --> Fulfilling_Standard : choose_carrier=standard
}
state "Shipped" as Shipped
Shipped : on enter#58; ship
state "Delivered" as Delivered
Delivered : on enter#58; deliver
Delivered : outcome#58; success
state "Cancelled" as Cancelled
Cancelled : on enter#58; cancel_order
Cancelled : outcome#58; cancelled
state "PaymentError" as PaymentError
PaymentError : on enter#58; on_payment_error
PaymentError : outcome#58; failed
state "FulfilmentError" as FulfilmentError
FulfilmentError : on enter#58; on_fulfilment_error
FulfilmentError : outcome#58; failed
Cart --> AwaitingPayment : PlaceOrder
AwaitingPayment --> Paid : PaymentAuthorized
state AwaitingPayment__choose <<choice>>
AwaitingPayment --> AwaitingPayment__choose : PaymentDeclined<br/>/ context.declines = context.declines + 1, context.last_decline = event.reason
AwaitingPayment__choose --> Retrying : [context.declines < context.max_retries]
AwaitingPayment__choose --> Cancelled : else
AwaitingPayment --> Cancelled : CancelOrder
Retrying --> AwaitingPayment
Paid --> Fulfilling
Paid --> PaymentError : error
Fulfilling --> FulfilmentError : error
Shipped --> Delivered : Delivered
Delivered --> [*]
Cancelled --> [*]
PaymentError --> [*]
FulfilmentError --> [*]
Fulfilling_Express --> Shipped : Dispatched
Fulfilling_Standard --> Shipped : Dispatched
What it shows:
A typed context (
context { declines: int = 0 max_retries: int = 1 ... }): the order’s own data, checked byharel validatewherever the model reads or writes it, and bycreate().A
choosewithsetonPaymentDeclined: the model counts the decline and retries whilecontext.declines < context.max_retries, through a transientRetryingstate, else cancels — a decision over the order’s own data, made in the model.A selector,
choose_carrier, inside theFulfillingcomposite: the carrier is a decision that belongs in code (rates, destination, weight);returns {"express", "standard"}letsharel validatecheck both branches are handled.on error: an unexpected exception incapture_payment, or anywhere insideFulfilling, goes to a failed terminal (PaymentError,FulfilmentError) instead of dead-lettering the order. A declined payment is not an error: it is a modelled event.CancelOrder, notCancel:Cancelbelongs to the control plane (runner.cancel()); cancelling an order is business, with its own event.
How it is built¶
order.stm— the machine.actions.py— the(stm, event, **inputs)actions, which record each step in the context’shistory(and raise when a scenario setssimulate_payment_errororsimulate_fulfilment_error), and thechoose_carrierselector.run.py— compiles the machine, prints its PlantUML, and drives each scenario with aDurableRunnerover aDictStore.
An action raising, routed by on error:
sequenceDiagram
participant S as run.py
participant R as DurableRunner
participant E as engine
participant A as capture_payment
participant D as DictStore
S->>R: process(id, PaymentAuthorized)
R->>E: process(exe, PaymentAuthorized)
E->>A: on enter Paid
A--xR: raises (gateway timeout)
R->>E: an error event (an on error is in scope)
E->>A: Paid to PaymentError: on enter on_payment_error
R->>D: commit(exe: PaymentError, DONE, outcome failed)
R-->>S: exe (PaymentError)
The step that raised is dropped: the order goes from AwaitingPayment straight to
PaymentError, with the exception in context._error.
How it runs¶
Semantics: synchronous —
DurableRunner, since each scenario reads the state after every event.Execution model: background, the default: a script, nothing to choose.
Action errors: an action error that nothing models would dead-letter the order (the runner’s
on_action_error="fail", its default); here the model handles both withon error, so it never comes to that.
Run it¶
uv run python -m examples.place_order.run
harel validate examples/place_order/order.stm order