Polymorphism
Object-Oriented Programming (OOP)
Consider a common scenario in backend services: processing different types of notifications. You might start by writing a processor method that looks like this, using an enum
to represent the notification type:
public enum NotificationType { EMAIL, SMS, PUSH }
public class NotificationSender {
public void send(Notification notification) {
// This switch statement is a code smell!
switch (notification.getType()) {
case EMAIL:
// Logic to connect to SMTP server, build email, send...
System.out.println("Sending an Email...");
break;
case SMS:
// Logic to connect to SMS gateway, format message, send...
System.out.println("Sending an SMS...");
break;
case PUSH:
// Logic to connect to APN/FCM, create payload, send...
System.out.println("Sending a Push Notification...");
break;
}
}
}
What's wrong with this? At first, it seems fine. But what happens when you need to add a new notification type, like SLACK
? You have to find this NotificationSender
class and modify its send
method.
This design violates the Open/Closed Principle: the NotificationSender
is not closed for modification. As the system grows, this switch
statement becomes a magnet for bugs and a central point of fragility.
Polymorphism
Polymorphism is the ability for a single interface to represent different underlying forms (or types). In practice, it means you can write code that operates on a parent type, and the correct behavior for the specific child type will be executed at runtime.
This is the principle that allows us to solve the problem above. Instead of a monolithic sender that knows how to handle every notification type, we can create multiple notification objects that each know how to send themselves.
Polymorphism is primarily achieved through inheritance (method overriding) and interface implementation, which we've already covered. Now we see why they are so useful.
Refactor: Sending Notifications
Let's refactor our NotificationSender
using polymorphism.
Step 1: Define a Common Interface (The Contract)
First, we define an interface that all notification types must adhere to. It has a single method, dispatch
.
public interface Notifiable {
void dispatch();
}
Step 2: Create Concrete Implementations
Next, we create separate classes for each notification type, each implementing the Notifiable
interface with its own specific logic.
public class EmailNotification implements Notifiable {
@Override
public void dispatch() {
// Logic to connect to SMTP server, build email, send...
System.out.println("Dispatching an Email...");
}
}
public class SmsNotification implements Notifiable {
@Override
public void dispatch() {
// Logic to connect to SMS gateway, format message, send...
System.out.println("Dispatching an SMS...");
}
}
public class PushNotification implements Notifiable {
@Override
public void dispatch() {
// Logic to connect to APN/FCM, create payload, send...
System.out.println("Dispatching a Push Notification...");
}
}
Step 3: Simplify the Sender
Our NotificationSender
is now much simpler. It no longer needs a switch
statement or any knowledge of the specific notification types. It just works with any object that is Notifiable
.
public class NotificationSender {
public void send(Notifiable notification) {
// No more switch! We just call the method on the object.
// The correct implementation is chosen at runtime.
notification.dispatch();
}
}
// In our main application:
List<Notifiable> notifications = List.of(
new EmailNotification(),
new SmsNotification(),
new PushNotification()
);
NotificationSender sender = new NotificationSender();
for (Notifiable n : notifications) {
sender.send(n); // Works for any type of notification
}
Now, if we need to add a SlackNotification
, we simply create a new class that implements Notifiable
. The NotificationSender
doesn't need to change at all. It is now closed for modification but open for extension.
Interview Focus: The "Tell, Don't Ask" Principle
This refactor is a perfect example of a key design principle to bring up in interviews: "Tell, Don't Ask."
- The old code (Asking): The
NotificationSender
had to ask the notification object for its type (notification.getType()
) and then decide what to do. The intelligence was in the sender. - The new code (Telling): The
NotificationSender
simply tells the notification object to do its job (notification.dispatch()
). The intelligence is distributed to the objects that hold the information.
This approach leads to more cohesive, encapsulated, and decoupled objects, which is a hallmark of good OO design.
Benefits of Polymorphism:
- Eliminates Conditional Complexity: It is the primary object-oriented tool for replacing
if/else
andswitch
statements. - Enables Extensibility: It allows you to introduce new functionality without modifying existing, stable code (Open/Closed Principle).
- Facilitates Frameworks and Plugins: This is how modern frameworks work. A web framework operates on a generic
Controller
interface; your specific controllers are "plugged in" and their methods are called polymorphically by the framework.