DEV Community

Dexterlung
Dexterlung

Posted on

The same bug, fixed three times: all my guards lived on the write side

There's a kind of bug that isn't hard to fix — it's just that it comes back.

I run a small coffee e-commerce platform, alone. One inventory bug had a very simple symptom: a coffee product was actually sold out. The admin panel showed it correctly — sold out. But a customer opening the storefront on their phone could still add it to the cart and place an order. Selling something you don't have is the kind of thing that makes an e-commerce person break into a cold sweat.

The first time I found it, I fixed it fast. The second time it showed up somewhere else, I fixed it again. The third time — when I noticed I was fixing "sold-out product is still orderable" for the third time — I stopped. Because a fourth time was just a matter of when.

That was the moment I realized: I shouldn't be fixing this bug. I should be asking why it keeps coming back.

Seven exits, the override patched on half of them

The stock status came out of one function — call it getProductStockStatus. Given green-bean stock, roasted-bean stock, and product state, it returned a verdict: is this product hidden, needs-roasting, sold-out, or sellable.

The problem was how it was written. It wasn't computing a result and returning once at the end — it was a chain of early returns. Hit a condition, return right there, skip the rest. Nothing wrong with that; lots of people write it that way and it reads fine.

But I had one extra requirement: I (the owner) can manually mark a product "sold out" in the admin panel, and that manual flag must override whatever the stock math says. So the manual-override check had to be pasted in front of every branch that could return "sellable."

That function had seven early-return branches.

You can probably guess what happened. Every time I added a new branch, I had a new exit, and I had to remember to paste the override check into that new exit too. I missed it three times. The first two were the branches where an anonymous user couldn't read the underlying bean-stock table — the function decided "can't read the base data" and returned early, before ever reaching the override. The third was a branch I added for an unrelated stock tweak, and forgot again.

Each "fix" was, in essence, going to the missed exit and pasting the override one more time. Tests were green right after, because I was testing the branch I'd just patched. But the moment I added the next exit, the bug had a seat reserved for its fourth revival. I wasn't curing it. I was re-manufacturing the exact conditions for the same bug, the exact same way, over and over.

The root cause wasn't in the bug. It was in how I fixed it.

The real fix is obvious once you say it out loud: don't have seven exits.

I refactored so every non-hidden branch returns the same finalize() function instead of returning a result directly. The override check lives inside finalize() — pasted exactly once.

// before: seven branches each return, override pasted 7x (missed 3)
if (!bean) return { soldOut: false, ... }   // <- miss 1, 2 (anon can't read bean stock)
if (someOtherCondition) return { ... }       // <- miss 3 (branch added later)
// ...four more

// after: every non-hidden branch goes through one exit; override applied once
function finalize(result) {
  if (manualSoldOut) result.soldOut = true   // the only override point
  return result
}
// each branch: return finalize({ ... })
Enter fullscreen mode Exit fullscreen mode

Behavior is identical — all hundred-plus existing tests stayed green. But structurally something changed: from now on, no matter how many branches I add, as long as it goes through finalize(), the override is applied. A new branch can't miss it, because there's no longer a "miss" action available. I turned a contract that depended on my memory into something you can't forget to do.

Then I pinned a test on the real function (not a drifted copy — I later found I had a test copy that didn't match the real function, and its branch was missing the override, so it was testing a fake). Assert: every non-hidden branch + manual sold-out → result is sold-out. I also did a mutation check: I manually reverted the refactor back to the missed early return, and the test went red immediately, including the anon point that caused the recurrences. Confirming the test actually bites, instead of being decoration.

All my defenses lived on the write side

After collapsing those seven exits, I didn't stop — because a bigger problem had surfaced.

I'd built a whole governance layer for this system: single-source-of-truth discipline, cross-layer data-contract audits, a thing I call trace lock (register a cross-layer data flow and pin it with a test so "changed A, forgot B" can't happen silently). I'm proud of it. It's caught a lot of cross-module breakage.

But staring at this bug that came back three times, something uncomfortable clicked: every anchor of my defense was on the write side.

All my rules govern "how does data get written," "is the write → middleware → render chain intact." The trace anchors are all write-side sources of truth. That approach kills a huge class of "the thing that got written is wrong" bugs.

But this inventory bug wasn't a write problem. The stock number in the DB was correct. The product state was correct. The manual sold-out flag was stored fine. The write side was flawless. The bug lived on the read side — the same stock data, read separately by many screens, each computing status its own way. My write-side guards simply don't reach the read surface.

And there's a nastier variant I now call auth-dependent SSOT. On the surface every screen shares the same getProductStockStatus function — looks fully single-sourced, looks converged to one place. But one of its inputs is a table only admins can read (bean-stock tables locked to is_admin() via row-level security). So the same function, fed an admin identity, computes "sold out"; fed an anonymous identity — because it can't read the bean data and returns early — computes "sellable."

Same function, different truth per identity. That's harder to catch than "two screens each rolling their own," because it looks like a single source already, so you don't suspect it. The heuristic I wrote down: "admin sees OK, anon sees broken" almost always means the frontend is computing from data only admins can read. The real fix isn't converging the function one more time — it's moving that computation to the database and storing it as a column anonymous users can read too.

Three gaps I left open (on purpose)

Laid out flat, this recurrence exposed three mechanisms my tooling still lacks — and honestly, all three are still unbuilt:

  1. Recurrence counting. My sentinel tools look at recent commit windows, but they don't track "how many times have I fixed this class of bug in six months." If it had told me "this is your third sold-out fix," I'd have stopped at the second. It needs a recurrence ledger.
  2. Root-cause forcing. I have tools that remind me after a fix to "go check for sibling structures with the same flaw," but they're all reactive. No rule proactively stops me at "you only fixed one read-side entry — what about the others?"
  3. Read-surface auto-extension. My trace lock only verifies that registered readers stay consistent. It doesn't scan for readers I forgot to register. That unregistered entry is exactly where the next recurrence breeds.

I wrote all three into my tech-debt backlog, high priority, and deliberately didn't rush to build them. The most valuable thing for a solo dev isn't patching every hole the moment you see it — it's seeing that the holes are all the same shape, then deciding which one is worth closing first.

But the inventory function itself is genuinely cured now. Not because I was more careful this time — because I finally stopped fixing it by "pasting the override into the exit I missed." When you catch yourself fixing the same bug a third time the same way, that bug stopped being a bug a while ago. The way you fix it is the bug.

Top comments (1)

Collapse
 
topstar_ai profile image
Luis

I appreciate how you broke down the problem of the recurring bug and identified the root cause as the multiple exit points in the getProductStockStatus function. The refactor to consolidate the override check into a single finalize() function is a great example of how simplifying code can make it more maintainable and less prone to errors. By reducing the number of exits and applying the override check in one place, you've effectively eliminated the possibility of missing it in future additions. Did you consider adding any additional tests or code reviews to ensure that new branches are properly routed through the finalize() function to prevent similar issues in the future?