If you open a file and immediately see a forest of if statements, switch es, and nesting that’s hard to follow, you’ve likely found a code smell. In object-oriented design, one powerful way to clean this up is Replace Conditional with Polymorphism — move the decision logic into different classes so each type knows how to behave.
Polymorphism lets a program call the correct implementation for an object even when the object’s concrete type is unknown in the current context. In many cases, you can eliminate conditionals and make your code clearer, easier to test, and easier to maintain.
This article will guide you step‑by‑step through why, when, and how to apply this refactor, with concrete examples and pitfalls to watch for.
Why bother?
Before we change anything, let’s agree on the benefits. Code with fewer conditionals is often:
- Easier to read. Each class focuses on one behavior, so the call site no longer needs to understand branching logic.
- Easier to test. Behavior is localized — you write focused unit tests for each concrete type instead of setting up many branching scenarios.
-
Easier to extend. Adding a new behavior usually means adding a new class instead of modifying existing
switch/iflogic.
That said, not every if is evil. Simple guards and primitive comparisons (>, <, ==) are fine. Our target is business logic branching where the system chooses different behavior based on type or a repeated condition.
When to replace conditionals with polymorphism
Use this refactor when you see either:
- State-based behavior: An object’s behavior changes based on its type ore internal state.
- Repeated conditionals: The same condition is checked in multiple places
Case 1 — State-based behavior
Suppose a method computes the speed of bird based on its type:
dobule getSpeed() {
switch(type) {
case EUROPEAN:
return getBaseSpeed();
case AFRICAN:
return getBaseSpeed() - getLoadFactor() * numberOfCoconuts;
case NORWEGIAN_BLUE:
return isNailed ? 0 : getBaseSpeed(voltage);
}
throw new RuntimeException("Should be unreachable");
}
Why this is a smell:
- The behavior for each bird type is mixed in one place.
- Adding a new bird requires changing this method.
- Callers of
getSpeed()must rely on a type field — they don’t work with a meaningful abstraction.
How to refactor?
- Introduce an abstract base class or interface
Birdwith an abstractgetSpeed()method. - Create subclasses:
EuropeanBird,AfricanBird,NorwegianBlueBird. - Move each branch of the
switchinto the corresponding subclassgetSpeed()implementation. - Replace usages of
type+switchwith polymorphic calls tobird.getSpeed().
Result: The decision logic travels to the types that own the behavior. Adding TropicalBird becomes a new class, not a modification. The call site becomes simpler and intention-revealing.
Another common example is an expression tree where a node can be either a value or an operator. A naive implementation that stores everything in one Node class often looks like this:
class Node {
char operator; // '#', '+', '*', ...
double value;
Node left, right;
double evaluate() {
switch (operator) {
case '#': return value;
case '+': return left.evaluate() + right.evaluate();
case '*': return left.evaluate() * right.evaluate();
// add new cases for each operator
}
throw new IllegalStateException("Unknown operator");
}
}
Problems with this design:
- The class holds fields that are irrelevant for some nodes (leaf nodes have
left/rightnull; operation nodes havevalueunused). - The
evaluate()method violates Open/Closed: every new operator requires modifying theswitch.
Refactor steps:
- Create an abstract
Nodewithabstract double evaluate(). - Create
ValueNodethat holdsvalueand returns it fromevaluate(). - Create an abstract
OperationNodethat holdsleftandrightnodes. - For each operation, create a concrete subclass:
AdditionNode,MultiplicationNode, etc., each implementingevaluate().
abstract class Node {
abstract double evaluate();
}
class ValueNode extends Node {
double value;
double evaluate() {
return value;
}
}
abstract class OperationNode extends Node {
protected final Node left, right;
OperationNode(Node left, Node right) { this.left = left; this.right = right; }
}
class AdditionNode extends OperationNode {
AdditionNode(Node left, Node right) { super(left, right); }
double evaluate() { return left.evaluate() + right.evaluate(); }
}
class MultiplicationNode extends OperationNode {
MultiplicationNode(Node left, Node right) { super(left, right); }
double evaluate() { return left.evaluate() * right.evaluate(); }
}
After refactor: Each node contains only the fields it needs. Behavior is defined by the concrete class. Adding a new operator means adding a new class — no change to existing classes.
Now the behavior is distributed into specific classes. Adding a new operator is as simple as adding a new subclass — no modification of existing classes is necessary. This respects the Open/Closed Principle and eliminates irrelevant fields and nulls, making each class focused on a single responsibility.
Case 2 — Repeated conditionals
Consider code that branches based on a flag and repeats that branch in many methods:
class Update {
void execute() {
if (FLAG_A) { /* do A */ }
else { /* do B */ }
}
void render() {
if (FLAG_A) { /* render A */ }
else { /* render B */ }
}
}
Why this is bad: The FLAG_A branching is duplicated — a single change will require updating multiple places.
Refactor steps:
- Introduce an abstract
Updatewith abstractexecute()andrender(). - Create
AUpdateandBUpdatesubclasses that implement the appropriate behavior. - Move the
FLAG_Adecision to a single place (factory, configuration loader, or composition root) that constructs eitherAUpdateorBUpdate.
abstract class Update {
abstract void execute();
abstract void render();
}
class AUpdate extends Update {
void execute() { /* do A */ }
void render() { /* render A */ }
}
class BUpdate extends Update {
void execute() { /* do B */ }
void render() { /* render B */ }
}
Where does the if go? Construction. A factory or composition layer decides which concrete Update to instantiate based on the flag.
class UpdateFactory {
static Update create(boolean flagA) {
return flagA ? new AUpdate() : new BUpdate();
}
}
Result: The conditional is localized in the factory; all consumers use polymorphism and no longer repeat the if.
Guildlines and trade-offs
- Use polymorphism when behavior differs by type/state or when the same conditional is repeated across methods
- Keep code readable: using polymorphism should reduce complexity, not introduce a confusing class explosion.
- Beware of runaway subclassing. If polymorphism would create dozens of tiny classes that are hard to manage, consider other patterns (strategy, composition, or data-driven approaches).
- Not all conditionals should be removed. Simple guards and primitive comparisons are often fine.
Step-by-step refactoring checklist
- Identify repeated or branching logic that selects behavior by type/state.
- Create an abstraction (interface or abstract class) for the behavior.
- Move each branch into a dedicated concrete implementation.
- Replace call sites with polymorphic calls to the abstraction.
- Move conditional(s) to a single, well-documented place (factory/DI).
- Add tests for each concrete implementation.
Summary
Replacing conditionals with polymorphism often makes code more readable, extensible, and testable. The pattern helps you follow Single Responsibility and Open/Closed principles by moving behavior into dedicated types. When you see repeated switch/if logic, ask whether types can own that behavior — and whether doing so would simplify the system overall.
References
Examples adapted from: The Clean Code Talks — Inheritance, Polymorphism, & Testing by Google TechTalks —https://youtu.be/4F72VULWFvc?si=v3kIuVEJSo29-TwJ
Top comments (0)