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 and switch 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.