Online edition for BLOGE
0.9.8-RC1· facts verified 2026-09-15 · 中文
Chapter 15 — State Machines
Promise: By the end of this chapter you will know when a workflow has outgrown a single DAG and needs a
state_machine, how to model event-driven transitions and timeouts, and how to keep explicit lifecycle state visible instead of hiding it in branches and flags.
Learning Goals
- Recognize when a problem is better modeled as named states and transitions instead of one branching graph.
- Define
initial, normal, andterminalstates with local graphs that do the work inside each state. - Use event-driven, guarded, automatic, and timeout transitions without mixing their responsibilities.
- Apply safety caps such as
max_transitionsandmax_state_visitsbefore a bad loop becomes a production incident. - Decide when a
state_machineis the right abstraction — and when a graph orsessionis still simpler.
Prerequisites
- Chapter 5 — Branches That Decide — state transitions solve a different problem than graph branches
- Chapter 12 — Waiting for the World — event-driven transitions still depend on the same external signalling model
- Chapter 13 — Durable Execution — state-machine checkpoints and timers still ride on durable foundations
- Chapter 14 — Multi-Turn Sessions — useful for comparing “conversation turns” with “named lifecycle states”
Source Examples
| File | What it shows |
|---|---|
ch14/order-lifecycle-state-machine.bloge | Draft → review → processing → completed lifecycle with timeout fallback |
ch14/ticket-state-machine.bloge | A broader support-ticket lifecycle with assign / escalate / resolve / close paths |
OrderLifecycleStateMachineExample.java | Fluent Java builder version using StateMachineBuilder |
bloge-state-ext/README.md | Runtime model, timeout semantics, and nested-session note |
ReviewStateMachineWithSessionExample.java | A preview of nested composition, expanded in Chapter 16 |
Why This Matters
A graph is excellent at answering one question: what can run now, given the current dependencies?
A state machine answers a different question: what state is this business object in, and what event moves it to the next state?
That distinction matters when the workflow revisits earlier states or must remain legible to humans outside the implementation team.
Examples:
- an order can move from
drafttopendingReview, back todraft, then forward again - a ticket can move from
opentotriaging, then eitherassignedorescalated - a paused review state can time out and return to an earlier state automatically
Branches, flags, and loops can encode those rules inside one large graph. The
lifecycle then remains implicit in node names and condition expressions. A
state_machine represents named states and transitions directly.
Mental Model
A state machine is an event-driven shell around per-state work.
| Piece | Responsibility |
|---|---|
StateMachineDef | Immutable definition of the whole machine |
state | Named lifecycle step such as draft, review, processing |
embedded graph | The work that runs while the machine is in that state |
| transition | Rule that moves to another state on event, timeout, or auto condition |
StateMachineExecutor | Starts the machine and delivers external events |
StateMachineCheckpoint | Serializable snapshot for restore and durable storage |
Four transition styles matter most:
| Transition style | Syntax | Use when |
|---|---|---|
| Event-driven | on approve -> processing | an external event should move the machine |
| Guarded | on * when ... -> approved | a state's output decides the next state |
| Automatic | on * -> completed | the machine should move immediately after state work finishes |
| Timeout-driven | timeout = 24h + on_timeout -> draft | the machine should fail over or rewind if nobody signals it |
The machine's job is to make lifecycle state visible. The embedded graph's job is to do local work inside each state.
First Working Example
The orderLifecycle example is the cleanest first state machine to study:
The 44-line definition is kept whole because the reader must see every state, event, timeout, and terminal transition as one lifecycle; isolated state fragments would recreate the ownership ambiguity this chapter removes.
state_machine orderLifecycle {
max_transitions = 25
max_state_visits = 5
timeout = 72h
state draft [initial] {
graph {
node initOrder : InitOrderOperator {
input {
orderId = ctx.orderId
customerId = ctx.customerId
}
}
}
on submit -> pendingReview
}
state pendingReview {
graph {
node reviewOrder : ReviewOrderOperator {
input {
orderId = ctx.draft.output.initOrder.orderId
}
}
}
on approve -> processing
on reject -> draft
timeout = 24h
on_timeout -> draft
}
state processing {
graph {
node fulfillOrder : FulfillmentOperator {
input {
orderId = ctx.pendingReview.output.reviewOrder.orderId
}
}
}
on * -> completed
}
state completed [terminal] { }
}
Read it as a lifecycle, not as a DAG:
- start in
draft - wait for
submit - move to
pendingReview - either
approve,reject, or time out back todraft - after
processingcompletes, auto-transition tocompleted
A Shared Definition Is Not a Running Order
orderLifecycle defines what every order may do. order-42 is one instance
currently in pendingReview; another instance can be in processing at the
same time. Mixing those two ideas is the fastest route to leaked state.
Read one instance as a sequence of accepted events
| Time | Event | Instance fact after acceptance |
|---|---|---|
t0 | create | currentState=draft, visits for draft = 1 |
t1 | submit | currentState=pendingReview, review graph output retained |
t2 | approve | currentState=processing, transition count = 2 |
t3 | wildcard completion | currentState=completed, terminal = true |
The definition contains state ids, graphs, guards, timeouts, and allowed transitions. The instance contains current state, visit/transition counters, state-local outputs, accepted event history, and checkpoint identity. The definition can be cached and shared; the instance must be isolated per order.
Change the definition, then ask what happens to the instance
Adding manualReview to the definition does not move order-42 there. An event
must select an allowed transition, and durable restore must decide whether the
old checkpoint is compatible with the new definition. This is why definition
versioning is a migration decision, not a live-state assignment.
Break It Apart
States hold local work; transitions hold lifecycle rules
Keep these responsibilities separate:
- the embedded
graph { ... }describes what happens inside a state - the
on ... -> targetrules describe when the machine changes state
When those responsibilities blur together, the definition becomes hard to reason about. A useful rule of thumb is:
- if the logic is about data dependency inside one visit, keep it in the state's graph
- if the logic is about changing lifecycle step, keep it in the transition list
Event-driven, automatic, guarded, and timeout transitions
state_machine supports more than one way to move forward:
- Event-driven:
on approve -> processing - Automatic:
on * -> completed - Guarded:
on * when ctx.review.output... -> approved - Timeout-driven:
timeout = 24handon_timeout -> draft
The timeout path is implemented as a synthetic timeout event behind the scenes. When a dedicated TimerService is present, paused instances can expire asynchronously; without one, the wall-clock limit is still enforced on the next execute(...) or signal(...) step.
Output shape is namespaced by state
State outputs are stored under the state ID, not flattened globally.
That is why the order example reads:
orderId = ctx.draft.output.initOrder.orderId
not simply:
orderId = ctx.initOrder.output.orderId
Namespacing keeps the lifecycle visible in downstream expressions. You can tell immediately which state produced the data.
Safety caps are part of the design, not an afterthought
The runtime ships with explicit runaway guards:
| Guard | Default | Purpose |
|---|---|---|
max_transitions | 100 | Caps total state transitions |
max_state_visits | 10 | Caps visits to any single state |
top-level timeout | none | Caps wall-clock lifetime of the entire machine |
state timeout | none | Caps how long a waiting state can remain paused |
These are especially important for backward transitions like reject -> draft.
Without the caps, a bad event stream or a bad guard can spin forever.
When a graph is still the better tool
Stay with a graph when:
- the work is a single pass through dependencies
- revisiting earlier named states is not part of the model
- you do not need external events to move between lifecycle stages
Stay with session when the core problem is a conversation across rounds.
Reach for state_machine when the core problem is an explicit lifecycle across states.
Global Transitions
So far, every transition in the examples is defined inside a specific state.
But some events should be handled from any state — cancellation, error
recovery, or global timeouts. Repeating the same on "CANCEL" -> cancelled
inside every non-terminal state is noisy and error-prone.
The global_transitions block solves this:
state_machine orderLifecycle {
global_transitions {
on "CANCEL" -> cancelled
on "ERROR" -> error_state
on "TIMEOUT" -> timed_out
}
initial_state = pending
state pending {
on "PAYMENT_RECEIVED" -> processing
on "EXPIRED" -> expired
}
state processing {
on "FULFILLED" -> completed
on "OUT_OF_STOCK" -> backordered
}
state completed { terminal = true }
state cancelled { terminal = true }
state error_state { terminal = true }
state timed_out { terminal = true }
state expired { terminal = true }
state backordered {
on "RESTOCKED" -> processing
}
}
How global transitions work
Global transitions are applied to every non-terminal state in the machine. They behave exactly like state-level transitions, but you write them once.
If a state defines its own transition for the same event, the state-level transition wins. This lets you override global behaviour when a specific state needs different handling.
When to use global vs state-specific transitions
| Use global transitions for | Use state-specific transitions for |
|---|---|
| Cancellation events that should work from any state | Events that only make sense in a specific lifecycle stage |
| Error fallback handlers | Guarded transitions based on state-local output |
| Global timeout escalation | Automatic transitions (on *) |
The rule of thumb: if removing the transition from one state would be a bug,
it belongs in global_transitions.
Common Trap
❌ Using a state machine for a straight DAG
If your whole process is really just:
validate -> price -> confirm
then wrapping it in states like validating, pricing, and confirming does not make the workflow clearer. It just adds ceremony.
Use a state machine when the state names communicate something the DAG alone does not — especially when external events, retries across states, or backward transitions matter.
What Goes Wrong
A backward transition burns through max_state_visits
Suppose pendingReview can reject back to draft, and a bad integration keeps publishing reject forever.
That is exactly why max_state_visits exists. The runtime stops the machine instead of looping silently forever.
The fix is not “raise the cap until the test passes”. The fix is to ask why the machine is cycling:
- is the transition guard wrong?
- is the external event wrong?
- is the domain lifecycle missing an intermediate state?
Test the loop intentionally before shipping it.
Guided Rewrite
Open ticket-state-machine.bloge.
Imagine you had started with one big graph plus branch nodes. The refactor into a state machine follows this sequence:
- Name the stable lifecycle states.
open,triaging,assigned,escalated,resolved,closed. - Move local work into state graphs. Receiving the ticket belongs inside
open; classification belongs insidetriaging. - Move event boundaries into transitions.
assign,escalate,resolve, andcloseare lifecycle events, not graph branches. - Add timeout behavior where silence has meaning.
resolvedtimes out toclosedafter the waiting window.
The refactor pays off when someone new joins the project. They can read the lifecycle from the state names alone, before they care about the operator details.
Brain Check
-
What problem does
state_machinesolve better than a single branching graph?(Explicit lifecycle modeling across named states and external transitions.)
-
What does
on * -> completedmean?(An automatic transition: after the state's graph finishes, move immediately to
completed.) -
Why does the order example read from
ctx.draft.output.initOrder.orderId?(Because outputs are namespaced by state ID, making lifecycle provenance explicit.)
-
What is the difference between the machine-level
timeoutand a state-leveltimeout?(The machine-level timeout caps the whole run; the state-level timeout caps how long one waiting state can remain paused.)
-
When should you avoid introducing a state machine?
(When the process is really just a one-pass DAG with no meaningful lifecycle states or external state transitions.)
-
Why is
max_state_visitsimportant for transitions likereject -> draft?(Because backward transitions can loop forever if bad events or bad guards keep revisiting the same state.)
Lab
Goal: model a real approval lifecycle as a state machine.
- Define states
draft,review,approved, andrejected. - Put the data-loading work in
draft. - Put review enrichment in
review. - Add transitions:
on submit -> reviewon approve -> approvedon reject -> draft
- Add
max_transitionsandmax_state_visitsexplicitly. - If review can stall, add
timeoutandon_timeouton the review state.
Stretch: add one guarded on * when ... -> approved transition so the state's output can auto-complete the lifecycle without a separate signal.
Durable State Machines
In-memory state machines lose their current state when the process restarts.
The bloge-state-durable module provides DurableStateMachineManager to
persist every transition as a checkpoint.
Maven dependency
<dependency>
<groupId>com.leanowtech.bloge</groupId>
<artifactId>bloge-state-durable</artifactId>
</dependency>
Builder and usage
Map<String, StateMachineDef> definitions = Map.of(
stateMachineDef.name(), stateMachineDef
);
DurableStateMachineManager durableSm = DurableStateMachineManager.builder()
.executionStore(executionStore)
.checkpointStore(checkpointStore)
.graphEngine(graphEngine)
.definitionLookup(definitions::get)
.recoveryConfig(StateMachineRecoveryConfig.defaultConfig())
.build();
durableSm.startup();
StateMachineResult waiting = durableSm.start(
"orderLifecycle",
Map.of("orderId", "ORD-123")
);
String executionId = waiting.instance().instanceId();
StateMachineResult result = durableSm.signal(
executionId,
"PAYMENT_RECEIVED",
Map.of("providerRef", "PAY-9")
);
start(...) creates and claims a durable execution. When the machine reaches
WAITING_EVENT, the manager persists a checkpoint and marks the execution
suspended. signal(...) claims that execution, loads the checkpoint, resumes
the definition selected by definitionLookup, and then delivers the event.
After startup(), the recovery loop scans expired claims and resumes from the
last committed checkpoint. Work after that checkpoint may be retried, so
external effects still need idempotency. Checkpoint/status atomicity additionally
depends on wiring a transactional persistence coordinator; the direct default
executes those writes independently.
This is the same checkpoint foundation introduced in Chapter 13 — Durable Execution, applied to the state-machine abstraction.
Experiment acceptance card
- Expected and observed: A shared definition does not share current state, visits, output, or checkpoint.
- Failure and recovery: Reuse one ID for two instances; recover unique identities and replay separately.
- Proof boundary: Proves definition/instance isolation, not trustworthy event sources.
- Exercise contract: Two ticket instances; change one instance ID; deliver side-by-side traces; stop when neither contaminates the other.
Recap
- A graph models dependency order; a
state_machinemodels lifecycle state. - Each state can run local graph work, but transitions are the main abstraction.
- Outputs are namespaced by state, which keeps provenance visible.
- Event, auto, guarded, and timeout transitions solve different problems.
- Global transitions handle events from any state — cancellation, errors, timeouts — without repetition.
- Safety caps such as
max_transitionsandmax_state_visitsare essential for backward transitions. DurableStateMachineManagerpersists transitions so machines survive restarts.- Reach for
state_machinewhen you need explicit states, not just more branches.
Next Step
In Chapter 16 — Composing Sessions and State Machines you will combine the two orchestration models. Some flows are conversations with an embedded lifecycle; others are state machines that need a short multi-step interaction inside one state.
Reference Links
bloge-state-ext/README.md— state-machine runtime overview and timeout semanticsOrderLifecycleStateMachineExample.java— Java builder exampleorder-lifecycle-state-machine.bloge— chapter-facing lifecycle exampleticket-state-machine.bloge— richer support-ticket lifecycle exampleReviewStateMachineWithSessionExample.java— nested session preview
Coding Agent: Open the versioned task guide.