← Concepts & practices
Concept Testing, debugging, and measurement

Debugging state and control flow

Make the wrong state explain itself.

An order workflow passes its happy path: pay, ship, deliver. Then a cancelled order ends up delivered. The final state tells you that the system is wrong, but not which event, guard, or branch made it happen. Reproduce the shortest failing sequence, record the state before and after each event, and fix the decision that the trace can actually name.

TypeScriptGo One state machine · four events · one wrong branch.

01 / The idea

A bad final state is a clue, not an explanation.

The order begins in cart. Paying moves it to paid; shipping moves it to shipped; delivery moves it to delivered. Cancellation should be terminal before delivery.

The ordinary sequence does not expose the mistake. Add cancel before deliver and the buggy transition accepts both shipped and cancelled as delivery sources. The failure is not “the order is weird.” It is a specific branch that admitted an impossible predecessor.

Reproduce the smallest sequence, then make each state transition visible.

expectedcancelledterminal state
→
observeddeliveredafter deliver was accepted
The final phase shows the symptom. The trace must show the transition that admitted it.
Read the starting transitionTypeScript · fair first answer, wrong guard
order.ts · buggy transition
// The first version has a control-flow bug: cancellation is treated as a
// delivery source as well as shipment.
export function applyEventBuggy(order: Order, event: OrderEvent): TransitionResult {
	if (event === 'pay' && order.phase === 'cart') {
		return accepted(order, event, 'cart → paid', 'paid');
	}
	if (event === 'ship' && order.phase === 'paid') {
		return accepted(order, event, 'paid → shipped', 'shipped');
	}
	if (event === 'cancel' && order.phase !== 'delivered') {
		return accepted(order, event, 'not delivered → cancelled', 'cancelled');
	}
	if (event === 'deliver' && (order.phase === 'shipped' || order.phase === 'cancelled')) {
		return accepted(order, event, 'shipped or cancelled → delivered', 'delivered');
	}
	return rejected(
		order,
		event,
		`${order.phase} cannot ${event}`,
		'event is not allowed from this phase'
	);
}

The first implementation already records a branch string, but its delivery condition includes cancelled. The happy path cannot distinguish a correct guard from a guard that is broader than the workflow allows.

02 / See the shape

Separate reproduction, observation, and repair.

The system under test is a transition function. replay applies events in order. A trace entry records the step number, previous phase, event, selected branch, resulting phase, and whether the event was accepted. That gives the debugger a stable vocabulary before it edits code.

The corrected transition and a replay function: every event leaves a trace entry, accepted or not.

TypeScriptReading
order.ts
export function applyEvent(order: Order, event: OrderEvent): TransitionResult {
	if (event === 'pay' && order.phase === 'cart') {
		return accepted(order, event, 'cart → paid', 'paid');
	}
	if (event === 'ship' && order.phase === 'paid') {
		return accepted(order, event, 'paid → shipped', 'shipped');
	}
	if (event === 'cancel' && order.phase !== 'delivered') {
		return accepted(order, event, 'not delivered → cancelled', 'cancelled');
	}
	if (event === 'deliver' && order.phase === 'shipped') {
		return accepted(order, event, 'shipped → delivered', 'delivered');
	}
	return rejected(
		order,
		event,
		`${order.phase} cannot ${event}`,
		'event is not allowed from this phase'
	);
}

export function replay(
	events: readonly OrderEvent[],
	transition: (order: Order, event: OrderEvent) => TransitionResult = applyEvent
): Order {
	return events.reduce((order, event) => {
		const result = transition(order, event);
		return result.order;
	}, initialOrder);
}
GoAlongside
order.go
func ApplyEvent(order Order, event OrderEvent) TransitionResult {
	if event == Pay && order.Phase == Cart {
		return accepted(order, event, "cart → paid", Paid)
	}
	if event == Ship && order.Phase == Paid {
		return accepted(order, event, "paid → shipped", Shipped)
	}
	if event == Cancel && order.Phase != Delivered {
		return accepted(order, event, "not delivered → cancelled", Cancelled)
	}
	if event == Deliver && order.Phase == Shipped {
		return accepted(order, event, "shipped → delivered", Delivered)
	}
	return rejected(order, event, string(order.Phase)+" cannot "+string(event), "event is not allowed from this phase")
}

func Replay(events []OrderEvent, transition func(Order, OrderEvent) TransitionResult) Order {
	order := initialOrder
	for _, event := range events {
		order = transition(order, event).Order
	}
	return order
}
Reading the TypeScriptA reducer, replay, and trace

applyEventBuggy and applyEvent share the same result shape. The only intentional difference is the delivery guard. firstInvalidTrace searches the evidence instead of guessing from the final phase. reduceSequence drops one event at a time and keeps any shorter sequence that still fails.

Reading the GoValue copies and explicit trace entries
order.go · corrected transition
func ApplyEvent(order Order, event OrderEvent) TransitionResult {
	if event == Pay && order.Phase == Cart {
		return accepted(order, event, "cart → paid", Paid)
	}
	if event == Ship && order.Phase == Paid {
		return accepted(order, event, "paid → shipped", Shipped)
	}
	if event == Cancel && order.Phase != Delivered {
		return accepted(order, event, "not delivered → cancelled", Cancelled)
	}
	if event == Deliver && order.Phase == Shipped {
		return accepted(order, event, "shipped → delivered", Delivered)
	}
	return rejected(order, event, string(order.Phase)+" cannot "+string(event), "event is not allowed from this phase")
}

func Replay(events []OrderEvent, transition func(Order, OrderEvent) TransitionResult) Order {
	order := initialOrder
	for _, event := range events {
		order = transition(order, event).Order
	}
	return order
}

Go uses value copies for the order and a slice for the trace. Both versions preserve rejected events as evidence while leaving the phase unchanged.

03 / Follow the trace

Watch a final symptom become a local decision.

The frames replay one order in four states: a happy path, the reported failure, the exact bad branch, and the corrected replay. Notice how the diagnostic question gets smaller at each step.

Debugging state

Make the wrong state explain itself.

happyphase: …
1. pay2. ship3. deliver
1cart → pay → paidcart → paid
2paid → ship → shippedpaid → shipped
3shipped → deliver → deliveredshipped → delivered

happy: order phase delivered; Every event follows the expected phase.

01/ 04
Replay the happy path

The happy path passes.

Pay, ship, and deliver is the path the original examples knew. It does not challenge cancellation.

Reduced motion: choose a scene to see its completed state.

Read this scene

Pay, ship, and deliver is the path the original examples knew. It does not challenge cancellation.

happy. Events: pay → ship → deliver. Phase: delivered. Every event follows the expected phase. The ordinary path passes.

Watch restarts when you return. Step through keeps your selected step. Try it starts a fresh trace.

What the trace buys you

A useful trace reduces ambiguity without turning every internal variable into public API.

Repeatability
The same event list reaches the same state, so a fix can be compared against known evidence.
Local blame
before, branch, and after show which decision admitted the wrong transition.
Minimal change
Once step 4 is named, narrow the delivery guard instead of rewriting the entire workflow.
Regression memory
Keep the failing sequence as a test so a later refactor cannot reopen the terminal-state bug silently.

The trace should serve a question. Section 08 names what happens when logs become noise or state snapshots lose causality.

04 / Try a decision

Choose the next diagnostic move.

The order ended delivered after cancellation. You can change code, change timing, or make the sequence and decisions observable. Which move gives you evidence first?

The order ended delivered after cancellation. What is the best next diagnostic move?

Make the failure small and expose the decisions that led to the final state.

05 / Give it a real job

Keep the reproduction beside the fix.

A production debugger needs a safe way to capture the triggering input, the relevant state, and the decision path. That might be a structured log, a trace ID, a replayable command list, or a focused failing test. Choose a representation that helps the next person rerun the cause without copying an entire environment.

After changing one guard, replay the same sequence and compare the before/after trace. Then add a regression test for the smallest durable failure and a broader boundary check if the state machine has more terminal paths.

Trace

Records every decision

Before, event, branch, and after for each step, rejected events included, so the log can answer “what was true before this branch?”

Reproduction

Keeps the failure repeatable

cancel → deliver, reduced from the reported four events, replayed against every candidate fix.

Regression test

Keeps it fixed

The same two events, with the expected phase and the rejected deliver in the trace.

Build UIs?Every status badge renders a state machine, and a component reducer makes you debug one.

Where it already is in your components

An order status badge is the last frame of somebody’s state machine. In the textbook version the component renders phase and lastTransition from the workflow, so when a customer reports “it says delivered, but I canceled”, the screen already shows which transition to replay on the server.

When you have to own it

The wild version derives its own label from a loose order and prints “Status shown locally”, so the clue is gone. The same thing happens when the machine lives in the component: a checkout step driven by useReducer in React, or a Svelte $state object that event handlers update. Then the transition is yours to debug.

The moves carry straight over. The actions are the events: record them, replay them through the reducer in a test, cut the click sequence down to the shortest one that still ends in the wrong step, and log before, action, and after at the one branch that accepts it.

The UI receives a workflow projection and renders the phase plus last transition.

ReactAlready in your code
textbook.tsx · workflow projection
type OrderView = {
	id: string;
	phase: 'cart' | 'paid' | 'shipped' | 'delivered' | 'cancelled';
	lastTransition: string;
};

export function OrderStatus({ order }: { order: OrderView }) {
	return (
		<article aria-label={`${order.id} status`}>
			<strong>{order.phase}</strong>
			<small>Last transition: {order.lastTransition}</small>
		</article>
	);
}

06 / Recognize it elsewhere

Follow state and decisions wherever symptoms travel.

The same debugging shape appears outside an order workflow.

Symptoms become useful traces
SymptomTrace before changing codeQuestion to answer
Unexpected UI modeAction, reducer state, selected branch, rendered projectionWhich event changed the mode?
Wrong authorizationSubject, resource, policy inputs, rule selected, decisionWhich condition granted access?
Bad import totalRow number, parsed value, validation result, accumulatorWhere did the first wrong value enter?
Retry never stopsAttempt count, error kind, backoff branch, next actionWhich path bypassed the limit?

07 / Already in your toolbox

Breakpoints, logs, and tests are different views of evidence.

A breakpoint lets you inspect a live decision. A structured log preserves a selected slice of that decision. A minimal reproduction makes it repeatable. A regression test keeps the cause from returning. Good debugging moves from the broadest symptom toward the smallest stable explanation.

Reproduce

Make it repeat

Capture the smallest input or sequence that reaches the bad state.

Observe

Expose decisions

Record the state before, event, selected branch, and resulting state.

Repair

Change one cause

Narrow the responsible condition and replay the same evidence.

08 / The parts to watch

More output can make a failure harder to see.

Changing too much at onceNo causal comparison

Keep the reproduction fixed while you change one guard or branch. A broad rewrite may remove the symptom without proving which cause mattered.

Logging only the final stateSymptom without path

The final phase says where you landed, not which event got you there. Include the predecessor and decision branch for transitions that matter.

A reproduction that depends on the environmentFailure disappears on replay

Remove network, wall-clock, random, and database dependencies when they are not part of the bug. If they are part of it, capture their inputs explicitly.

Fixing the symptom in the UIA second state machine appears

Do not make a component relabel or repair an impossible domain state. Fix the transition owner, then render a canonical projection.

09 / Make the call

Use the smallest evidence that can distinguish causes.

Diagnostic depth
Start hereWhen it is enoughAdd next
Minimal reproductionThe failure repeats with a short input or event list.State and branch trace.
State traceThe final symptom has multiple plausible predecessors.Boundary values and external inputs.
Boundary captureThe cause crosses a process, queue, browser, or database.Trace ID and contract-level replay.
Regression testThe smallest durable cause is understood.Keep it beside broader behavior coverage.

Reproduce before speculating.

Trace the state before changing the control flow.

Keep this questionAsk it when a bug report arrives.

What is the smallest input that repeats the failure, and what decision changed the state from the last value I trusted?

10 / Take the idea with you

Explain the bug without saying “debugging.”

“A four-event report reduces to cancel → deliver. At step 2 the order was cancelled, and the delivery branch accepted it because its guard allowed two source phases. We narrowed the guard to shipped and replayed the same two events until deliver was rejected.” That tells a reviewer the reproduction, the branch, and the evidence for the fix. When they want the words, they are minimal reproduction, trace, and guard.

Before moving on, jot down why pay → ship → cancel → deliver was not the reproduction to keep, what the trace entry at step 2 says, and the last bug in your own code you fixed before you could make it happen twice.

Connections to follow nextRelated lessons

Take the order workflow into your editor. Add a refund event that is only accepted after delivered, then replay every two-event sequence and check the trace for the first one that shouldn’t pass.

Back to Concepts & practices →