Replace Conditional With Polymorphism
Worth doing when the variation is stable, meaningful and repeated across several operations. Not worth doing to most switch statements, where the switch is clearer than what replaces it.
The requirement, the obvious build, and why it breaks
Every lesson starts where the work starts: someone asked for something, and the first implementation that comes to mind survives until the requirement changes.
When does turning a conditional into a set of types make the code easier to change, and when is the conditional simply the right answer?
A payment module has a switch (provider) in four different methods — authorize, capture, refund and reconcile — each with the same five branches. Adding a sixth provider means finding all four.
Conditionals on a type code are a smell; replace them with polymorphism. Every switch on an enum should become a set of classes implementing an interface.
Applied to the currency switch, it produces three classes to hold one line each, and a reader who wants to know what happens for EUR now has to find the class instead of reading three lines in a row (Over-Decomposition).
- Applied to the currency switch, it produces three classes to hold one line each, and a reader who wants to know what happens for EUR now has to find the class instead of reading three lines in a row (Over-Decomposition).
- It scatters the comparison. The single most valuable property of a switch is that all cases are visible together, and polymorphism deliberately destroys that — which is a large loss when the cases need to be compared and a small one when they never are (Local Reasoning).
- It makes adding an *operation* harder in exchange for making adding a *type* easier. If the operations change more often than the providers, the refactoring has moved the cost onto the more frequent change (Open/Closed, Critically).
- It hides exhaustiveness. In a language with checked exhaustive matching, the switch already fails to compile when a case is missing; the polymorphic version relies on an abstract method and a wiring step instead, which is weaker unless the language enforces it.
- And the "conditionals on type codes are a smell" rule has no way to distinguish the payment case from the currency case, which is the entire judgement being asked for.
What limits the solution, and what must never stop being true
This domain leads with these two. A design that ignores its constraints is not a design, and an invariant nobody named is one nothing is protecting.
- The five providers are genuinely different: different auth flows, different refund windows, different reconciliation formats.
- Providers are added roughly twice a year and removed almost never.
- The team is comfortable with interfaces, and the language has them.
- There is also a
switch (currency)elsewhere with three branches in one method, and someone has proposed the same treatment for it.
- Every provider must implement every operation, or the system must fail loudly at wiring time rather than at three in the morning (Validate at Startup, Fail Loudly in Backend).
- Adding a provider must not require editing existing providers.
- The set of providers is knowable — something, somewhere, must be able to enumerate them for reconciliation (Explicit State).
Who owns what, and where the seams fall
Responsibilities decide boundaries; boundaries decide what an interface has to say.
- Each provider type owns everything about that provider — auth, capture, refund, reconciliation format — so the knowledge that varies together lives together (Cohesion).
- The calling code owns the workflow and knows nothing about which provider it has (Strategy).
- Something owns the mapping from a stored provider code to an implementation, and owns failing loudly when a code has no implementation.
- A single-use conditional owns nothing. It is a branch, and it should stay one.
- The condition for this refactoring is repetition across operations. One switch is a branch; the same switch in four methods is a type that has not been declared (Shotgun Surgery).
- The second condition is stability of the axis. Polymorphism commits to a fixed set of operations and an open set of types; if the operations are what keep changing, it is the wrong shape (Choosing the Model).
- The third is meaning.
provideris a domain concept with behaviour attached.currencyin that other switch is a lookup key with a rounding rule, and there is no behaviour to distribute (Value Objects).
Four switches, and one that should stay
The two conditionals below live in the same codebase, a hundred lines apart. One of them is a type that has not been declared; the other is a lookup with a rounding rule. Nothing about their syntax distinguishes them.
// in authorize(), capture(), refund(), reconcile()
switch (order.provider) {
case 'stripe': return stripeAuth(order)
case 'adyen': return adyenAuth(order)
case 'braintree':return braintreeAuth(order)
case 'paypal': return paypalAuth(order)
case 'invoice': return invoiceAuth(order)
}
// A sixth provider = five branches x four methods,
// found by grep. Each provider's knowledge is
// spread across four files.switch (currency) {
case 'JPY': return Math.round(amount)
case 'KWD': return round(amount, 3)
default: return round(amount, 2)
}
// A new currency = one line, here.
// Five classes would hold one expression each,
// and "what happens for JPY" would stop being
// answerable at a glance.The payment switch is repeated across four operations, and each case carries substantial behaviour that changes together — that is a type with four methods, currently expressed as four parallel switches, which is why adding a provider is a grep. The currency switch appears once, each case is one expression, and the cases have no independent reasons to change. Converting it would add five files, five indirections and one registry to express three lines, and would make the only interesting question — how do the rounding rules compare — harder to answer. The rule "switch statements are a smell" cannot tell these apart; the change history can (The Rule of Three).
Which direction do you want to be open in?
This refactoring is a bet on which axis of change arrives more often. It is not a bet you can hedge: making it cheap to add a type makes it expensive to add an operation, and vice versa. That is the expression problem, and every design in this area picks a side.
The decision below is the honest form of the question, and note that two of the five options are "do not do this refactoring".
- The payment example is the third row. The currency example is the fifth, and arguably the first.
- The fourth row is the most under-used: the full hierarchy is often three times more machinery than the variation justifies.
- If you cannot say which axis changes more often, you do not yet have the information to make this decision, and the switch is the reversible option (Reversible and Irreversible Decisions).
Which arrives more often — a new case, or a new operation over all cases?
when It appears once, the cases are one-liners, and no new case is expected.
cost Nothing. This is the correct answer for most switch statements, and choosing it costs only the discomfort of not applying a known refactoring (YAGNI, With Its Bill Attached).
when The language checks exhaustiveness, and new operations arrive more often than new cases.
cost Adding a case breaks every match site — which is the *feature*: the compiler enumerates the work. Cases stay visible together. Costs you nothing when adding operations (Making Illegal States Unrepresentable).
when The same cases are switched on in several operations, each case has real behaviour, and new cases arrive more often than new operations.
cost Adding an operation now touches the interface and every implementation. Cases are no longer comparable at a glance, and selection moves into a registry that must be validated (Wiring and the Composition Root).
when Only one of the operations actually varies; the others are the same for every case.
cost Much less machinery than a full hierarchy and it fits the actual variation. Easy to miss because the full refactoring is the one with a name (Strategy).
when Each case maps to data — a rate, a precision, a format string — rather than to behaviour.
cost The cheapest option of all, and frequently the right one. The cases stay visible together and adding one is a row. Fails as soon as a case needs genuine behaviour rather than a value.
What each option actually costs
Scored across the axes the decision actually moves. The numbers are a model rather than a measurement, and the ordering within a column is the content — not the magnitudes.
| Option | Simplicity | Flexibility | Testability | Migration cost | Note |
|---|---|---|---|---|---|
| Four parallel switches (today) | Simple to read one at a time and impossible to keep consistent. Adding a provider is a grep across four files, and a missed switch is a silent partial implementation. | ||||
| Sum type, exhaustive match | Cases stay visible together and the compiler enumerates every site when a case is added. Best option where the language supports it; unavailable where it does not, and it does not localise a provider's knowledge into one file. | ||||
| One class per provider | A provider's knowledge lives in one file and a contract test can be run against all of them. Adding an operation touches six files, and the registry becomes a thing that can be wrong at runtime (Contract Tests). | ||||
| Lookup table of data | Unbeatable when the variation really is data. Collapses the moment one case needs behaviour, and the collapse is usually handled by adding a callback to the table, which is the worst of both. |
caveat These scores describe one situation — five providers, four operations, roughly two new providers a year — and they invert if operations change more often than providers, which is the single fact that decides this and is not represented anywhere in the table. Nothing here was measured; it is a model of the trade, and its purpose is to make the axis of variation an explicit input rather than an assumption.
How to build it
Most important first.
- Count the switches. Four switches on the same enum, each covering the same branches, means the variation is real and repeated — that is the trigger, and one switch is not (The Rule of Three).
- Check the axis. Ask which arrives more often: a new provider, or a new operation on all providers. Polymorphism makes the first cheap and the second expensive (Open/Closed, Critically).
- Introduce the interface from the caller's side — the four operations the workflow needs, and nothing else (Designing a Module Interface).
- Move one provider at a time, keeping the switch as a fallback until the last is moved. Every step deployable (The Refactoring Loop).
- Keep the mapping from code to implementation in one place, and validate at startup that every stored code has one (Validate at Startup, Fail Loudly in Backend).
- Leave the currency switch alone. Three branches, one method, no repetition, no behaviour — the switch is the clearest possible expression of it.
What the next change costs
The field this whole domain exists for. A structure is only better if it makes the change after this one cheaper — and it is worth saying which changes it does not help.
- Before: adding a provider costs finding four switches, adding five branches, and hoping there is not a fifth switch somewhere. The discovery is the expensive part, and it grows every time someone adds a switch (Shotgun Surgery).
- After: adding a provider costs one new file and one registry line. Nothing existing is edited, so nothing existing can regress.
- What gets more expensive: adding a new *operation* — say
disputeEvidence()— now costs an interface change plus five implementations, where before it was one new switch. If operations are added more often than providers, this refactoring made the common case worse. - The currency switch, if converted: adding a currency costs a class instead of a line, and reading what happens for EUR costs a jump instead of a glance. Nothing got cheaper, permanently (The Cost of Change).
- You lose the ability to see all the cases at once, which is a real and frequently underrated cost — comparing five refund windows across five files is much harder than reading one switch.
- You gain flexibility along one axis and pay for it along the other. There is no version of this that makes both directions cheap.
- Indirection: a reader following a payment now goes through an interface to a class chosen at runtime, and for someone debugging an incident that is a genuine obstacle (Debuggability by Design).
What can go wrong
- Applied to a small switch, producing a class hierarchy with more ceremony than logic (Speculative Generality).
- A shared abstract base class that accumulates the parts two providers happened to share, after which a third provider needs half of it and the hierarchy contorts (Composition Over Inheritance).
- The interface grows to the union of everything any provider needs, so most implementations have empty methods — a signal the abstraction is wrong (Interface Segregation, Critically).
- Exhaustiveness is lost: a provider code exists in the database with no implementation, and the failure is a null at runtime rather than a compile error (Error Modeling).
- The mitigation fails: a team adds a registry with a default no-op implementation to avoid crashes, and silent no-op payments are far worse than the crash.
- The caller depends on the interface only, which is the point: adding a provider adds a file and touches no existing behaviour.
- Each implementation depends on that provider's SDK, which localises those dependencies instead of importing five SDKs into one module (Do We Need a Package for This?).
- The registry depends on all implementations, which is an acceptable, single, deliberate fan-out — and it is where a missing provider must be detected (Wiring and the Composition Root).
- "Switch statements are a smell." Frequently they are the clearest expression available, and in a language with exhaustive matching over sum types they carry a compile-time guarantee that polymorphism does not (Making Illegal States Unrepresentable).
- "This is the Open-Closed Principle, so it is always right." OCP names a direction of extensibility. Choosing which direction to be open in is the whole decision, and being open in the wrong one is worse than being closed (Open/Closed, Critically).
- "Polymorphism removes the conditional." It moves it to wherever the implementation is selected. There is still a mapping from data to behaviour; it is now in a registry, which is better only when the alternative was four copies (Wiring and the Composition Root).
- "Each branch should become a subclass." Composition usually fits better than inheritance here — a strategy object per provider rather than a hierarchy — and inheritance brings coupling the problem never asked for (When Inheritance Fits).
- shotgun-surgery
- divergent-change
Testing it, and how it ages
- One test suite per implementation, plus one shared contract test asserting the properties every provider must satisfy — this is where a polymorphic design pays back in testing (Contract Tests).
- A test that the registry covers every provider code that appears in the database, run at startup or in CI. This replaces the exhaustiveness the compiler gave you with the switch.
- For the refactoring itself, no test should change while providers are being moved one at a time (What Refactoring Actually Is).
- The design ages well while the operation set is stable and badly when it is not. Five years of adding providers is the good case; a year of adding operations across five providers is the bad one.
- Shared behaviour between providers tends to appear. Putting it in a base class is the tempting move and usually the wrong one; composition into a shared helper keeps the hierarchy flat (Composition Over Inheritance).
- What forces a rethink: two providers that differ in only one method. That is a sign the axis of variation is narrower than the interface, and a strategy for the one varying operation may be all that was needed (Strategy).
Where this applies
This domain's advice is contested more than most. These labels say what each claim is specific to — and where CONTESTED appears, the note gives the strongest form of the opposing view rather than a caricature.
- PARADIGM-SPECIFICIn an OO language this is a class per case; in a functional language it is a record of functions or a sum type with an exhaustive match, and the sum type keeps the cases visible together while still centralising them. The functional version therefore loses much less than the OO version does, which is why this refactoring is argued about far more in OO communities.
- LANGUAGE-SPECIFICWhere the compiler checks exhaustiveness — Rust, Swift, TypeScript with a discriminated union and a never-check, Java with sealed types — a switch already guarantees that adding a case breaks every site that must handle it. That guarantee is the main safety argument for polymorphism, so in those languages the case for this refactoring rests only on cohesion, and it is correspondingly weaker.
- CONTESTEDThe strongest opposing view, and it is a strong one: a switch keeps all variation visible in one place where it can be compared and reasoned about, while polymorphism scatters it across files that must be opened one at a time — and the scattering is permanent while the flexibility is often hypothetical. Practitioners in this camp point out that "add a case to a sum type and let the compiler find every site" is a better workflow than "add a class and hope the registry is complete", and that most switch statements never grow the third or fourth sibling that would justify the change. They are right about most switches. The refactoring earns its keep specifically when the same set of cases is switched on in several operations and each case has substantial behaviour, which is a much narrower situation than the rule as usually stated.
Where the depth lives
This domain teaches the codebase-level structure and hands the rest off.
- — Programming Languages & Runtime Internals — this is the expression problem, and how much it hurts depends on whether the language gives you exhaustive matching, open sum types or extension methods.