We did the same thing over and over again in a post series: took a conditional that branched on a type code and replaced it with polymorphism. Type code to a class, then to subclasses, and finally to State/Strategy when the variant changes at runtime. By the end, we were switch-case ninjas.
Then we moved on to the next feature and shipped it behind a flag, wrote if(flags.isEnabled("...")), and walked straight back into the thing we spent a month killing.
Feature flags are the most common conditional in a modern codebase, and almost nobody treats them as code. A flag is a string in a dashboard and an if in a service. But a flag that picks between two (or more) behaviors is the same structure as a switch on a type code. Same scattered branches, same string keys, same slow rot.
The fix is the one we already know. And there’s one move past it that makes a flag safe to add and, finally, safe to delete.
The Flag That Ate the Service
It starts with one flag, one branch:
public Money quote(Shipment shipment) {
if (flags.isEnabled("use-new-rate-engine")) {
return newRateCalculation(shipment);
}
return oldRateCalculation(shipment);
}Reasonable. Temporary1, even. We'll delete the old branch once the new engine is proven. Except the new engine grows its own rollout flag for a surcharge that's only on for a few enterprise accounts:
public Money quote(Shipment shipment) {
if (flags.isEnabled("use-new-rate-engine")) {
Money base = newRateCalculation(shipment);
if (flags.isEnabled("enterprise-surcharge-v2")) {
return base.plus(enterpriseSurcharge(shipment));
}
return base;
}
return oldRateCalculation(shipment);
}Then a third flag arrives, and the developer who adds it does the rational thing: copies the nearest isEnabled block and edits it2. Six months later quote() is forty lines of nested string checks, two flags are permanently on, one is permanently off, and nobody remembers what enterprise-surcharge-v2 was meant to replace, so it stays.
This is the part that makes flags worse than a plain switch. A switch on an enum is at least closed: the compiler knows every case. A string flag is open. Anyone can add one from anywhere, and the dead ones never leave, because deleting a branch we don’t fully understand is scarier than leaving it. So they pile up.
It’s the Smell We Already Killed
Let’s strip the string and look at the structure. quote() branches on use-new-rate-engine the same way a switch branches on a type code. The key is a string from a config service instead of an enum, and the branches are flag states instead of type values. Underneath, it’s identical: scattered conditional logic, picking behavior at runtime, growing every time someone adds a case.
We already named this smell, and we already killed it. We just never pointed the technique at flags, because flags don’t look like type codes. They look like configuration. But a feature flag is a type code in config’s clothing.
Each Variant Becomes a Class
The fix is the same. Each branch becomes a class behind an interface:
public interface RateEngine {
Money quote(Shipment shipment);
}
public class LegacyRateEngine implements RateEngine {
public Money quote(Shipment shipment) { /* ... */ }
}
public class NewRateEngine implements RateEngine {
public Money quote(Shipment shipment) { /* ... */ }
}The business logic stops branching. It asks a RateEngine for a quote and gets one:
public Money quote(Shipment shipment) {
return rateEngine.quote(shipment);
}The branch doesn't vanish. It moves to one place, where the flag picks which RateEngine to hand over:
RateEngine selectRateEngine() {
if (flags.isEnabled("use-new-rate-engine")) {
return new NewRateEngine();
}
return new LegacyRateEngine();
}If this feels familiar, it should. This is Replace Type Code with State/Strategy, the final post in that series, pointed at a flag instead of a type. The variant is chosen at runtime, the behaviors are interchangeable, and the conditional is gone from the business logic. Nothing new so far.
But a flag has a property that a type code doesn’t, and that property changes everything.
A Flag Is Supposed to Die
A type code is permanent. A pet is a dog or a cat for as long as it lives. EXPRESS and STANDARD shipments will both still exist next year. The variants are part of the domain.
A flag is not. A flag is a temporary fork in the road. It exists to carry one behavior past one rollout, and then one side is supposed to win while the other is supposed to be deleted. use-new-rate-engine has a job: keep the old engine reachable until the new one is trusted. The day the new one wins, the flag and the losing branch should both be gone.
That’s the rule everyone agrees with and nobody follows. Deleting a flag means finding every selectRateEngine that mentions it, removing the right branch, deleting the dead class, and trusting that we found them all. It’s manual, it’s spread across the codebase, and a half-finished deletion leaves a flag that’s wired in two places and read in one. So the safe move is to leave it. No wonder dead flags accumulate.
The polymorphism cleaned up the branching. It did nothing for the deletion. For that, the wiring itself has to know which flag owns which class.
Generate the Dispatch
Instead of writing the selection by hand, we declare it on the classes and let an annotation processor generate the wiring at compile time:
@FlagVariant(flag = "use-new-rate-engine", whenEnabled = false)
public class LegacyRateEngine implements RateEngine { /* ... */ }
@FlagVariant(flag = "use-new-rate-engine", whenEnabled = true)
public class NewRateEngine implements RateEngine { /* ... */ }The processor reads those annotations and generates the selector we wrote by hand a moment ago, plus the wiring that injects the right RateEngine wherever one is needed. The business code asks for a RateEngine and never sees the flag at all.
Three things fall out of moving the dispatch to compile time:
No string keys in our code. The flag name appears once, on the variant, not scattered across every
isEnabledcall.No reflection. The dispatch is generated source, so it’s as fast and as debuggable as the hand-written version, and the compiler checks it.
Deletion becomes mechanical. Delete
LegacyRateEngineand its annotation. The processor regenerates the dispatch without it, and anything that still expects the old behavior fails to compile, in the editor, before the build.
That last one is the whole point. A flag we can retire by deleting a class is a flag that will actually get retired.
I maintain a small annotation processor that does exactly this, called FlagZen3. But the tool is not the idea. The idea is that flag dispatch is code, code can be generated, and generated dispatch is the only kind that’s safe to throw away. Any annotation-processing stack can do it.
Flags Are a Code Pattern, Not Config
Step back and the shift is bigger than one selector. We stopped treating the flag as a string that lives in a dashboard and started treating it as a structure that lives in the code.
The dashboard keeps its job. It flips the switch at runtime, decides which accounts get the new engine, and ramps the rollout from one percent to a hundred. What it no longer holds is the branching logic. The “what happens when this flag is on” moved out of scattered if blocks and into a class with a name.
Once a flag is a class behind an interface, it inherits everything we like about classes:
It’s testable in isolation.
NewRateEnginegets a unit test, not a flag-flip-and-pray integration run.It’s extensible. A third variant is a third class, not a third level of nesting.
It’s disposable. One variant, one class, one delete.
That’s the difference between configuration scattered through business logic and a code-organization pattern. Same feature flags, same dashboard, completely different blast radius when they change.
The Takeaway
A feature flag that branches on a string is a switch-case with a config file taped over it. Give each branch a class, generate the dispatch, and retiring a flag becomes a key deletion instead of an archaeology dig.
We spent four posts learning to replace conditionals with polymorphism. Feature flags are just the conditionals we didn’t recognize, because they arrived dressed as configuration. The technique was always going to work here. We only had to see the flag for what it is: a type code that’s allowed (and supposed) to die.
TLDR
Feature flags written as
if/elseon string keys are the same smell as switch-case on type codes: scattered conditional logic that grows without bound.The fix is the same, polymorphic dispatch. Each variant becomes a class behind an interface, and the flag picks which one, in one place instead of everywhere.
An annotation processor can generate that dispatch at compile time: no string keys in our code, no reflection, and a deletion that the compiler verifies.
The real win isn’t the framework. It’s treating a flag as code we can test and delete, not configuration scattered through business logic.
Try This Week
Find a feature flag in your codebase. Trace every if branch it touches and count them. Now picture each branch as its own class behind one interface. Which version would you rather test? Which would you rather extend six months from now, when nobody remembers what the flag was for?
Media attributions:
Cover image by the author
My dad once made a temporary Hi-Fi stand. We used it for 20 years.
We can date a piece of code by its flag names. First use-new-rate-engine, then use-new-rate-engine-v2, then use-new-rate-engine-v2-final. (That reminds me of pre-git “version control” sending zipped folders in emails.)

