systematic debugging
the iron law: no fixes without root-cause investigation first. symptom patches are failure, however fast they appear to work.
what it teaches
four phases, in order, each complete before the next:
- Root-cause investigation — read errors completely, reproduce consistently, check recent changes, in multi-component systems instrument every boundary (log what enters and exits each layer) so one run shows where it breaks. trace bad values backward up the call stack to their origin.
- Pattern analysis — find a working example of the same thing, read the reference implementation completely, list every difference from the broken one, however small.
- Hypothesis and test — one specific, written hypothesis; smallest possible change to test it; one variable at a time. a failed hypothesis produces a new hypothesis, never a stacked fix.
- Implementation — failing test reproducing the bug, single fix addressing the root cause, verify nothing else broke.
tripwire: three or more failed fixes means stop fixing and question the architecture. each fix revealing a new problem in a different place is the signature of a wrong structure, not a wrong line.
why it exists
guess-and-check thrashes — slower than investigation, every time:
- each speculative fix is a change someone must unwind before the real cause is visible.
- retries and timeouts bury the original error.
- a fix that "works" removes the pressure to investigate while the fault stays.
- after three failed fixes in different places, the evidence is contaminated and the structure — not a line — is the suspect.
red flags: "quick fix for now, investigate later", "just try changing X" — the self-test against entering that spiral.
the rules
- proposing a fix before Phase 1 is complete is the violation, even if the fix turns out right.
- fixes come one at a time, each verified, no bundled improvements.
- "No root cause found" is 95% of the time an incomplete investigation.
boundary instrumentation is phase 1 in code — one run then shows where the bad value enters:
// instrument every boundary before changing anythingasync function handleWorkflowStart(command: StartCommand) {log.info("workflow.start.received", { command });const config = await providerAdapter.resolve(command.providerId);log.info("workflow.start.adapter_output", {providerId: command.providerId,config,});const result = await orchestrator.start(config, command);log.info("workflow.start.orchestrator_output", { result });return result;}// one failing run proves which boundary first produced the bad value.// the failing test reproducing the bug is written before the fix.
when to use
any bug, test failure, unexpected behavior, performance problem, build failure, or integration issue — especially under time pressure, when the process matters most.
good and bad
bad
symptom: workflow start failedfix: add a retry (two attempts) and redeployresult: workflow still fails, now tries twice and fails twicecost: wasted two invocations, harder to diagnose because error is buried in retry logicnext fix attempt: maybe the timeout is too short? add more delaysthree retries later: workflow fails predictably on every attemptactual root cause (finally found): required binding was never deployed
retrying a systematic failure (missing configuration) only delays discovery: each guess burns cost, adds complexity, and contaminates the evidence — every stacked retry, timeout, and workaround must be unwound before the true cause is visible.
good
symptom: workflow start failedinvestigation phase 1: read error message, see configuration-missingevidence: provider adapter returned configuration-missing diagnosticphase 2: reproduce reliably and instrument the boundaryobserved fact: the binding is never read by the workerhypothesis: required binding is absent in deploymentphase 3: check recent changes and deployment logsfound: the binding was removed in the last migration but not re-createdfix: add binding to deploymentverify: workflow starts and completes successfully
investigation finds the cause before any change. the single targeted fix either works or shows a different, real error; nothing speculative to remove later; the same failure class stays fixed everywhere. debugging time shrinks with each root-cause fix instead of growing.