Skip to main content

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

  1. Recognize when a problem is better modeled as named states and transitions instead of one branching graph.
  2. Define initial, normal, and terminal states with local graphs that do the work inside each state.
  3. Use event-driven, guarded, automatic, and timeout transitions without mixing their responsibilities.
  4. Apply safety caps such as max_transitions and max_state_visits before a bad loop becomes a production incident.
  5. Decide when a state_machine is the right abstraction — and when a graph or session is still simpler.

Prerequisites

Source Examples

FileWhat it shows
ch14/order-lifecycle-state-machine.blogeDraft → review → processing → completed lifecycle with timeout fallback
ch14/ticket-state-machine.blogeA broader support-ticket lifecycle with assign / escalate / resolve / close paths
OrderLifecycleStateMachineExample.javaFluent Java builder version using StateMachineBuilder
bloge-state-ext/README.mdRuntime model, timeout semantics, and nested-session note
ReviewStateMachineWithSessionExample.javaA 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 draft to pendingReview, back to draft, then forward again
  • a ticket can move from open to triaging, then either assigned or escalated
  • 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.

PieceResponsibility
StateMachineDefImmutable definition of the whole machine
stateNamed lifecycle step such as draft, review, processing
embedded graphThe work that runs while the machine is in that state
transitionRule that moves to another state on event, timeout, or auto condition
StateMachineExecutorStarts the machine and delivers external events
StateMachineCheckpointSerializable snapshot for restore and durable storage

Four transition styles matter most:

Transition styleSyntaxUse when
Event-drivenon approve -> processingan external event should move the machine
Guardedon * when ... -> approveda state's output decides the next state
Automaticon * -> completedthe machine should move immediately after state work finishes
Timeout-driventimeout = 24h + on_timeout -> draftthe 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.

Diagram: 15-state-machines figure 1


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 to draft
  • after processing completes, auto-transition to completed

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.

Diagram: state-machine definition and one instance trace

Read one instance as a sequence of accepted events

TimeEventInstance fact after acceptance
t0createcurrentState=draft, visits for draft = 1
t1submitcurrentState=pendingReview, review graph output retained
t2approvecurrentState=processing, transition count = 2
t3wildcard completioncurrentState=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 ... -> target rules 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

Diagram: 15-state-machines figure 2

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 = 24h and on_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:

GuardDefaultPurpose
max_transitions100Caps total state transitions
max_state_visits10Caps visits to any single state
top-level timeoutnoneCaps wall-clock lifetime of the entire machine
state timeoutnoneCaps 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 forUse state-specific transitions for
Cancellation events that should work from any stateEvents that only make sense in a specific lifecycle stage
Error fallback handlersGuarded transitions based on state-local output
Global timeout escalationAutomatic 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:

  1. Name the stable lifecycle states. open, triaging, assigned, escalated, resolved, closed.
  2. Move local work into state graphs. Receiving the ticket belongs inside open; classification belongs inside triaging.
  3. Move event boundaries into transitions. assign, escalate, resolve, and close are lifecycle events, not graph branches.
  4. Add timeout behavior where silence has meaning. resolved times out to closed after 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

  1. What problem does state_machine solve better than a single branching graph?

    (Explicit lifecycle modeling across named states and external transitions.)

  2. What does on * -> completed mean?

    (An automatic transition: after the state's graph finishes, move immediately to completed.)

  3. Why does the order example read from ctx.draft.output.initOrder.orderId?

    (Because outputs are namespaced by state ID, making lifecycle provenance explicit.)

  4. What is the difference between the machine-level timeout and a state-level timeout?

    (The machine-level timeout caps the whole run; the state-level timeout caps how long one waiting state can remain paused.)

  5. 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.)

  6. Why is max_state_visits important for transitions like reject -> 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.

  1. Define states draft, review, approved, and rejected.
  2. Put the data-loading work in draft.
  3. Put review enrichment in review.
  4. Add transitions:
    • on submit -> review
    • on approve -> approved
    • on reject -> draft
  5. Add max_transitions and max_state_visits explicitly.
  6. If review can stall, add timeout and on_timeout on 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_machine models 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_transitions and max_state_visits are essential for backward transitions.
  • DurableStateMachineManager persists transitions so machines survive restarts.
  • Reach for state_machine when 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.


Coding Agent: Open the versioned task guide.