Stop fixing bugs. Start fixing classes.
Every non-trivial bug is one instance of a class. Patching the instance leaves the class alive — and it will mint the next bug. The lens, the patch+class split, the economics, and the trap of fixing classes that don't matter.
AppX team ·
A bug report lands. Stack trace, repro steps, user waiting. The reflex is: find the smallest change that makes this specific report go away, ship it, close the ticket, move on.
That reflex is wrong for any bug that isn't a typo.
Every non-trivial bug is one instance of a class — a shape that, given the underlying design, was going to produce many bugs over time. You patched the instance. The class is still alive. It will mint the next bug in three months, with a different stack trace, in a different file, and the engineer who fixes it won't connect the two.
The reflex
Watch a team in incident mode. The pressure is to make the red thing green. The patch that does that is almost always tiny — a null check, a missing await, a column name corrected, a guard clause. Ship, close, done. The dashboard goes quiet. Everyone exhales.
Six weeks later: a different team, different feature, different file. Same shape. Same root cause, in fact, just refracted through a different code path. Nobody flags the connection because nobody wrote down what the class was. There was just "that bug we fixed" and now "this new bug."
This is the dominant failure mode of mid-sized engineering teams. Not bad fixes — correct fixes, scoped one instance at a time, accumulating a graveyard of live classes.
The lens
The shift you want is from "this bug" to "this kind of bug."
When a report lands, ask one question before you ask anything else:
What would need to be different about the design for this bug to be impossible?
The answer tells you the class.
If the answer is "nothing — anyone can have a typo, that's why God made code review," the class is trivial. Patch and move on.
If the answer is "the writer would have to participate in a two-phase commit," you've found a class.
If the answer is "the schema would have to enforce a CHECK constraint that rejects this row," you've found a class.
If the answer is "the column reference would have to be typed at compile time so the compiler refuses to build a SQL string with a wrong name," you've found a class.
The pattern: when the structural fix is bigger than the patch, you've found a class worth naming. Write it down. Now you have two tickets, not one.
Patch + class, both
The instance fix and the class fix are different work items. They live on different timescales. They get different reviews.
The instance fix ships this hour. A user reported it. A user needs to see it gone. You don't hold the patch hostage to a six-week refactor — that's how class-fix discipline collapses into "we never ship anything."
The class fix gets its own ticket, its own planning, its own rollout. It is usually 5x the work. It usually touches schema, types, or a load-bearing abstraction. It usually requires a migration. That's fine. It deserves the room.
The shape, roughly:
// instance fix (ships this hour)
- WHERE status = 'compelte'
+ WHERE status = 'complete'
// class fix (ships this quarter)
CREATE TYPE order_status AS ENUM ('pending', 'complete', 'failed');
ALTER TABLE orders
ALTER COLUMN status TYPE order_status USING status::order_status;
-- now misspellings fail at write time, not at read time, forever
The patch makes the report go away. The class fix makes the next twelve reports impossible.
Three classes we've found
Generic shapes, drawn from real outages. Each one started as a single instance. Each one had siblings waiting.
Class: silent zero-row writes from stringly-typed column references. Raw SQL with column names interpolated as strings. Template typo, no compile-time check, write succeeds against zero rows, no error. You only find out because a downstream read returns stale data hours later. Instance patch: rewrite the one query. Class fix: ban raw column references in templates, route everything through typed bindings. Sibling post: ban-raw-column-references.
Class: NULL-outcome rows accumulating because two-phase writes have a death window. A row is created in phase one to reserve a slot, then updated in phase two with the actual outcome. If the process dies between the two phases — and processes die — you get a permanent NULL row. The dashboard shows it as "in progress" forever. Instance patch: hand-fix the rotten rows. Class fix: single-row-per-turn invariant, written once at terminal state, plus a DB CHECK constraint that rejects the half-built shape. Sibling post: the-0ms-zombie.
Class: defensive meta?.X ?? meta?.Y ?? meta?.Z walks because the same field has been renamed three times inside a JSON blob. Every reader has to defend against the union of all historical names. Every writer has to remember which name is current. New names get added; old names never get removed because someone might still be reading them. Instance patch: pick one branch, document it. Class fix: promote the field to a typed column, ban new JSON-stuffing as the default. Sibling post: typed-columns-over-metadata-json.
In all three cases the instance fix was a one-liner. In all three cases the class fix was a schema migration with backfill. In all three cases, the class fix paid back inside a quarter — usually within weeks — because the bug had three or four siblings already filed under different titles.
When NOT to fix the class
Class-fixing is not a virtue you spend down on every bug. The math has to work.
Don't fix the class when:
- The class produces a bug every five years and the fix is a six-month refactor. The expected value is negative. Patch and move on.
- The class is in a subsystem you're already planning to delete next quarter. Don't refactor a corpse.
- The class lives entirely inside code you don't own — a vendor SDK, a generated client, a third-party service. Wrap it, contain it, don't try to fix it upstream unless they're receptive.
- You can't articulate the class clearly. If the best you can say is "code in this area is messy," that's not a class, that's a vibe. Vibes don't refactor cleanly. Wait until the shape sharpens.
The rule of thumb: a class deserves a fix when it has measurable recurrence (you can point to three or more instances over the last year) or measurable blast radius (one instance was a P0). Below that bar, name the class, file it, and move on. The cost of naming is near zero; the value compounds the next time a sibling shows up and someone says "oh, that's the same class as the one in the doc."
Takeaways
- Every non-trivial bug is an instance of a class. Patching the instance leaves the class alive. The class will mint the next bug.
- Before asking "what is the patch," ask "what would need to be different about the design for this to be impossible?" The answer is the class.
- Ship the instance fix this hour. File the class fix as a separate ticket with its own plan, review, and rollout. Don't conflate them.
- Class fixes cost roughly 5x an instance patch and usually pay back within a quarter — they pay back the first time a sibling bug doesn't happen.
- Reserve class fixes for classes with three-plus historical instances or measurable blast radius. Vibes are not classes.
- A backlog of unfixed classes is the dominant root cause of mid-team P0s. The cheapest moment to fix a class is when its first instance lands. The most expensive is after the third.