Somewhere in a billing service I inherited, there was a function that calculated fees differently depending on payment provider — Stripe, PayPal, a couple of regional processors. It was an if/elif ladder, about nine branches deep, and it offended me the way if/elif ladders offend most people who’ve read a design patterns book. I refactored it into a Strategy pattern: one class per provider, a common interface, a registry to look them up. It was better code, by every metric that shows up in a linter. It made the next incident worse.
What went wrong
A new payment provider’s fee calculation had an edge case around currency rounding. Under the old if/elif version, you’d find the whole rounding logic for every provider in one function, in one file, and you could read all nine branches side by side to see how each one handled the edge case, then copy the pattern that worked. Under the Strategy version, the same information was spread across nine files, each implementing a common interface but with no forced proximity — so understanding “how does everyone else handle currency rounding” meant opening nine files and holding the pattern in your head across all of them, instead of scrolling one function.
The abstraction was correct in the “each provider is a variant of the same operation” sense. It was wrong in the “how do people actually debug fee calculation” sense — which was almost always by comparing providers against each other, not by working within one provider’s isolated logic.
The actual heuristic
The Strategy pattern (or any polymorphism-over-conditionals refactor) pays for itself when:
- The branches are added independently, by different people, at different times — a plugin architecture where new payment providers get added by different teams without touching the others. Isolation is a feature here, because nobody needs the full context of every other branch to add theirs.
- The branches don’t need to be compared against each other to reason about correctness. If understanding branch 3 never requires looking at branch 7, isolation costs you nothing.
It doesn’t pay for itself when:
- The branches are small, tightly related variations on the same core logic, and the natural debugging workflow involves comparing them side by side. Nine files you have to open in sequence is strictly worse than one function you can scroll through, even if the function is long.
- The set of branches is closed and rarely changes. Polymorphism’s main structural benefit — adding a new case without modifying existing code (open/closed principle) — is worthless if you’re not actually adding cases often. You paid the indirection cost for an extensibility benefit you never cashed in.
What I’d do differently
I’d have kept the if/elif ladder, and split it only if we’d actually started onboarding new payment providers at a rate that made the file unwieldy — which, a year later, we hadn’t. The lesson wasn’t “if/elif is fine, patterns are overrated.” It was that I refactored based on what the code looked like in isolation, not based on how the team actually used it during an incident. That second question is the one that should decide it.