June 17, 2026 in fundamentals5 minutes
A intermediate-level guide to Polymorphism: before-and-after java code and diagrams for a CS student.
Imagine you are building a payment system. Initially, you only support credit cards. But then, your business grows, and you must add PayPal, Apple Pay, and Bitcoin. You find yourself writing more and more if/else statements to check the type of every incoming object so you can call the correct method.
This is a code smell known as “Switch Statements” (or in Java’s case, an if-else ladder using instanceof). Instead of letting the objects handle their own logic, your main processor has to become an expert on every single payment type in existence. Every time you add a new method of payment, you have to modify this central PaymentProcessor class.
class PaymentProcessor {
public void process(Object payment) {
if (payment instanceof CreditCard) {
CreditCard cc = (CreditCard) payment;
System.out.println("Processing credit card: " + cc.getNumber());
} else if (payment instanceof PayPal) {
PayPal pp = (PayPal) payment;
System.out.println("Processing PayPal account: " + pp.getEmail());
}
}
}
class CreditCard {
private String number;
public CreditCard(String number) { this.number = number; }
public String getNumber() { return number; }
}
class PayPal {
private String email;
public PayPal(String email) { this.email = email; }
public String getEmail() { return email; }
}As shown in the diagram below, the PaymentProcessor is tightly coupled to every specific implementation. It must know exactly what a CreditCard and a PayPal object looks like to work with them:
classDiagram
class PaymentProcessor {
+process(Object payment)
}
class CreditCard {
-String number
+getNumber() String
}
class PayPal {
-String email
+getEmail() String
}
PaymentProcessor ..> CreditCard : checks instanceof
PaymentProcessor ..> PayPal : checks instanceof
To fix this, we use a technique called Replace Conditional with Polymorphism.
First, we need to understand two foundational concepts: Inheritance and Method Overriding. Inheritance allows us to create a “parent” class (a supertype) that defines a general behavior. Method overriding is when a “child” class (a subtype) provides its own specific version of that behavior.
Polymorphism is the ability of different objects to respond to the same method call in their own unique way. Think of a universal remote control: you press the “Power” button (the general action), but the actual result depends on whether you are pointing it at a TV or a DVD player (the specific object).
We can solve our problem by creating an abstract Payment class. Instead of the processor asking, “Are you a credit card?”, it simply says to the payment object, “Process yourself!”
abstract class Payment {
public abstract void process();
}
class CreditCard extends Payment {
private String number;
public CreditCard(String number) { this.number = number; }
@Override
public void process() {
System.out.println("Processing credit card: " + number);
}
}
class PayPal extends Payment {
private String email;
public PayPal(String email) { this.email = email; }
@Override
public void process() {
System.out.println("Processing PayPal account: " + email);
}
}
class PaymentProcessor {
public void process(Payment payment) {
payment.process();
}
}Now, the PaymentProcessor doesn’t care which specific type of payment it receives; it only cares that the object is a Payment. This makes our system extensible: you can add a new Bitcoin class without ever touching the PaymentProcessor code again.
classDiagram
class Payment {
<<abstract>>
+process() void
}
class CreditCard {
-String number
+process() void
}
class PayPal {
-String email
+process() void
}
class PaymentProcessor {
+process(Payment payment)
}
Payment <|-- CreditCard
Payment <|-- PayPal
PaymentProcessor --> Payment : processes
You might wonder how the computer knows which process() method to run if the code just calls payment.process(). This happens through runtime dispatch.
When you compile your code (the process of translating your text into instructions the computer understands), the computer sees that Payment has a process() method. However, when you actually run the program (runtime), the Java Virtual Machine looks at the actual object in memory. If that object is a CreditCard, it jumps to the CreditCard version of the method.
This “decision” happens while the program is running, which is why we call it dynamic or runtime behavior. This is different from method overloading, where you have multiple methods with the same name but different parameters that are decided at compile-time.
sequenceDiagram
participant P as PaymentProcessor
participant C as CreditCard
P ->> C: process()
C -->> P: return
Polymorphism is a powerful tool for adhering to the Open/Closed Principle (the idea that code should be open for extension but closed for modification). You should reach for it when you see if-else or switch blocks that are branching based on an object’s type.
However, do not use polymorphism for everything. If your logic only ever has two possibilities and those possibilities will never change (like a simple Boolean check), creating a whole class hierarchy adds unnecessary complexity known as “over-engineering.” Polymorphism is best when you expect the number of subtypes to grow over time.
if/else ladders that check for specific object types.The client uses the same method call regardless of the specific payment type.
PaymentProcessor processor = new PaymentProcessor();
processor.process(new CreditCard("1234-5678"));
processor.process(new PayPal("user@example.com"));