Facebook Pixel

Polymorphism

Polymorphism is what happens when multiple types respond to the same method call, each in its own way, without the caller needing to know which type it is talking to. The caller asks a NotificationChannel to send() a message. Whether that resolves to an email, an SMS, or a push notification is the channel's problem, not the caller's. The caller is freed from the branching logic, and each type owns its own behavior.

The caller is also insulated from future changes: adding a new channel means adding a new class that implements the interface, without modifying the caller.

The class structure that makes this work is straightforward: a single interface defines the shared operation, and each concrete type provides its own implementation.

The switch-on-type smell

The canonical sign that polymorphism is missing is a conditional block that checks a type field and dispatches to different code paths. Every time a new type is added, the conditional grows. The logic for each type is scattered across the codebase rather than living with the type itself.

1# Bad: NotificationService inspects a type string and branches.
2# Adding a new channel (e.g., "slack") means editing this method.
3class NotificationService:
4    def send(self, channel_type: str, recipient: str, message: str) -> None:
5        if channel_type == "email":
6            print(f"[EMAIL] To {recipient}: {message}")
7        elif channel_type == "sms":
8            # SMS has a 160-char limit — that rule lives here, not with the channel
9            if len(message) > 160:
10                message = message[:157] + "..."
11            print(f"[SMS] To {recipient}: {message}")
12        elif channel_type == "push":
13            print(f"[PUSH] To device {recipient}: {message}")
14        else:
15            raise ValueError(f"unknown channel type: {channel_type}")
1from abc import ABC, abstractmethod
2
3# Good: each channel type handles itself.
4# NotificationService is oblivious to which channel it is using.
5class NotificationChannel(ABC):
6    @abstractmethod
7    def send(self, recipient: str, message: str) -> None:
8        """Deliver message to recipient via this channel."""
9
10class EmailChannel(NotificationChannel):
11    def send(self, recipient: str, message: str) -> None:
12        print(f"[EMAIL] To {recipient}: {message}")
13
14class SmsChannel(NotificationChannel):
15    MAX_LENGTH = 160
16
17    def send(self, recipient: str, message: str) -> None:
18        # SMS truncation rule lives with the channel that owns it
19        if len(message) > self.MAX_LENGTH:
20            message = message[: self.MAX_LENGTH - 3] + "..."
21        print(f"[SMS] To {recipient}: {message}")
22
23class PushChannel(NotificationChannel):
24    def send(self, recipient: str, message: str) -> None:
25        print(f"[PUSH] To device {recipient}: {message}")
26
27class NotificationService:
28    def __init__(self, channel: NotificationChannel) -> None:
29        self._channel = channel
30
31    def notify(self, recipient: str, message: str) -> None:
32        self._channel.send(recipient, message)   # no branching

Why this matters in practice

The rule that goes with SMS truncation — messages over 160 characters get cut — lives inside SmsChannel in the good design. In the bad design, it lives inside NotificationService.send(). That placement matters when requirements change: if SMS later moves to 140 characters for a specific carrier, the bad design requires you to find and edit the branching block. The good design requires you to edit exactly one class — the one that represents SMS.

Each type owns the rules for its own behavior. The code for each variant is co-located with the type, not scattered across every caller that needs to branch on it.

The tradeoff: traceability

Polymorphism aids extensibility but can make control flow harder to trace. When you read channel.send(recipient, message), you cannot tell from that line alone which implementation will run — you have to know what channel was set to at construction time. In a large system with many layers of injection, tracing the call requires following the object graph back to where it was wired together.

The switch-on-type version is easier to trace: the entire dispatch logic is in one place. Polymorphism trades that local traceability for extensibility and cohesion. The tradeoff favors polymorphism when the set of types is expected to grow; for a fixed, small set of types that will not change, the indirection may not be warranted.

Interview framing

When you see a method that switches on a type string, a status enum, or a category field — and each branch does different work — that is the signal to consider polymorphism. Define an interface that captures the shared operation (here: send), move each branch into its own implementing class, and let the caller depend on the interface.

The follow-up question interviewers often ask is "what if we need to send to multiple channels at once?" The polymorphic design handles this cleanly: introduce a MultiChannel that holds a list of NotificationChannel objects and calls send on each. That class is a new implementation of the same interface — the caller does not change at all.

Invest in Yourself
Your new job is waiting. 83% of people that complete the program get a job offer. Unlock unlimited access to all content and features.
Go Pro