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.
Read the starting transitionTypeScript · fair first answer, wrong guard
// 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.
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);
} 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
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.
Make the wrong state explain itself.
cart → pay → paidcart → paidpaid → ship → shippedpaid → shippedshipped → deliver → deliveredshipped → deliveredhappy: order phase delivered; Every event follows the expected phase.
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, andaftershow 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?
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.
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?”
Keeps the failure repeatable
cancel → deliver, reduced from the reported four events, replayed against
every candidate fix.
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.
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.
| Symptom | Trace before changing code | Question to answer |
|---|---|---|
| Unexpected UI mode | Action, reducer state, selected branch, rendered projection | Which event changed the mode? |
| Wrong authorization | Subject, resource, policy inputs, rule selected, decision | Which condition granted access? |
| Bad import total | Row number, parsed value, validation result, accumulator | Where did the first wrong value enter? |
| Retry never stops | Attempt count, error kind, backoff branch, next action | Which 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.
Make it repeat
Capture the smallest input or sequence that reaches the bad state.
Expose decisions
Record the state before, event, selected branch, and resulting state.
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.
| Start here | When it is enough | Add next |
|---|---|---|
| Minimal reproduction | The failure repeats with a short input or event list. | State and branch trace. |
| State trace | The final symptom has multiple plausible predecessors. | Boundary values and external inputs. |
| Boundary capture | The cause crosses a process, queue, browser, or database. | Trace ID and contract-level replay. |
| Regression test | The 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
- Invariants and example tests names the state rule a trace protects.
- Property-based testing can search for related event sequences and shrink them the way you reduced this one.
- Diagnosing concurrency bugs extends the same discipline to timing and interleavings.
- Fakes, stubs, and mocks helps isolate collaborators while you reproduce the policy.