Lindsay Edwards

Make the rule a failing test: ratchets, tripwires, and a refactor that stayed boring

On this page

Most codebases carry a list of things everyone agrees are wrong and nobody has time to fix. Controllers that reach past their layer. Sync-over-async in a dozen spots. A tenant filter that most, but not all, of the tables actually apply.

The usual fate of that list is to grow. Here is a way to make it only ever shrink, and a couple of related tricks for making dangerous changes boring, all from a large .NET codebase.

Freeze the number, forbid it from rising#

Instead of trying to fix every instance of an anti-pattern at once, you write a test that counts them and freezes the count. Controllers touching the database directly: 60. Hand-rolled pagination: 10. Direct cache injection: 15. Sync over async: 0, hold that line.

New violations push the count above the frozen ceiling and fail the build. Fixing old ones lets you lower the ceiling. The number can go down and can never go up. People call this a ratchet, and the name is exactly right: it only turns one way.

What it buys you is honesty. You stop pretending you will fix all 60 today, and you stop the 61st from ever sneaking in. “We should clean this up someday” becomes an enforced, visible boundary that improves monotonically, one pull request at a time.

The same idea, pointed at security#

The sharpest version of this was for tenant isolation. In a multi-tenant system, every table carrying a user id needs a query filter so one tenant can never read another’s rows. An audit found 198 entities that had the id but were missing the filter: a real cross-tenant leak surface.

You cannot fix 198 things safely in an afternoon. So they froze the exemption list and added a floor assertion pointing the other way: more than 50 entities must have the filter applied. If someone breaks the query-filter wiring, that count collapses and the test screams before the leak ever ships. The thing that floor guards is concrete: a missing filter lets one signed-in customer’s query return another customer’s rows, so switching it off silently means serving people data that was never theirs.

A ceiling stops things getting worse. A floor stops a critical invariant getting silently switched off. Security-critical wiring wants a floor.

Making a scary refactor boring#

The same instinct rescued a 2,500-line startup file: one enormous pile of dependency registration and pipeline wiring, the kind of file where a rename can compile perfectly and then explode at runtime because something no longer resolves.

Before touching it, they wrote a characterisation test that boots the real application and asks it to resolve every service the app registers, collecting any failure into a list and asserting the list is empty. “Does the whole thing still wire up?” became a fast, deterministic check.

Only then was the file carved into a dozen smaller ones, one commit per chunk, re-running the resolution test after each. It went from 2,500 lines to 264, and the scary part, the wiring, was never actually scary, because a green test after every step meant nothing had come unhooked.

The pattern under all three#

A rule that lives in someone’s head gets broken the week they are on leave. A rule that lives in a passing test gets broken exactly once, in the pull request that broke it, with a message that says so.

Ratchets for the mess you cannot fix today. Floors for the invariant you cannot afford to lose. A resolution test for the change you are afraid to make. In every case the move is the same: take the thing you are relying on a human to remember, and hand it to the build instead.

Keep reading