Debugging by coincidence is making changes until the symptom disappears. The fix “works” in the sense that the error message goes away. Nobody knows the root cause. Two months later the same bug resurfaces in a different shape, and nobody knows why, because nobody ever understood why the first time.

Form a falsifiable hypothesis before you touch code instead: “I believe the bug is in X because Y. If I’m right, evidence Z should be there. If I’m wrong, evidence W should be there instead.” It’s the scientific method applied to debugging, and most engineers skip it in favor of “let me try this and see.”

Being wrong is fine — it’s expected, and it’s still progress, as long as the hypothesis was specific enough to be wrong about something. “I believe the timeout is caused by the retry loop, so I’d expect the logs to show three attempts before the 500” is falsifiable: check the logs, see two attempts, and you’ve just ruled out the retry loop entirely, with evidence, in the time it took to grep a log file. “Maybe it’s something with retries” isn’t falsifiable — no evidence confirms or rules it out, so being wrong about it teaches you nothing and you’re back where you started. The goal was never to be right on the first guess. It’s to make every guess narrow the search.

Stay outside the code as long as you can

The longer you can stay outside the code, the sharper the hypothesis you bring back into it. A ticket says “customers report transfers aren’t completing.” The rabbit-hole move is to open TransferService and start reading. The narrowing move is to ask cross-cutting questions that don’t require a single file open yet:

  • Every customer, or one segment?
  • Every transfer type — wire, ACH, internal — or one specifically?
  • Every channel this can be triggered from — mobile, web, branch — or one?
  • Started at a specific time, or always been intermittent?

Answer those from a support dashboard or a logs query, not from source, and “transfers aren’t completing” might turn into “wire transfers only, only for accounts opened after last Tuesday’s migration, ACH and internal transfers are fine.” That’s not a vague area to start reading in anymore — it’s a hypothesis: something about the new-account wire path changed on the migration date. You’ve ruled out 90% of TransferService before opening it, because whatever’s wrong has to be true of wires and false of ACH, true of new accounts and false of old ones.

This is harder to stick to now than it used to be, not easier. It takes real discipline to run those cross-cutting checks first when you could paste the ticket straight into an AI coding assistant and have it start proposing diffs in TransferService within seconds. The assistant can only reason from what it can see, and what it can see is code — so it reasons from the code outward, same as you do if you follow it in immediately. The shortcut feels like speed. What it’s actually doing is skipping the ten minutes of narrowing that would have told you which twenty lines of that file actually matter, in favor of confidently investigating all of it.

The minimal reproduction

The minimal reproduction is the most important artifact this discipline produces. Building one forces you to separate what’s essential from what’s incidental:

// Minimal reproduction becomes the regression test
func TestFormatAccountHolderName_NilProfile(t *testing.T) {
    customer := Customer{ID: 1, Profile: nil}
    got := formatAccountHolderName(customer)
    if got != "Unknown" {
        t.Errorf("formatAccountHolderName(nil profile) = %q, want %q", got, "Unknown") // was panicking
    }
}

The practical test for whether you’re done: can you explain why the fix works? “I’m not sure, but it stopped happening” means the bug isn’t fixed, it’s masked. The same conditions will produce it again. You just haven’t seen it yet.