Skip to main content
Clean Architecture 80 XP · 8 min

Polymorphism: The Power of Plugins

Polymorphism lets you treat external dependencies — database, UI, I/O — as interchangeable plugins, making core logic device-independent.

Showing
Ad (728×90)

Why this matters

Before OOP, calling a function required knowing its address at compile time — or using a pointer, which was error-prone and undisciplined. OOP's contribution to architecture is not encapsulation or inheritance: it is safe, disciplined polymorphism through interfaces. An interface makes function pointers implicit, typed, and safe.

This is architecturally transformative. When you depend on an interface rather than a concrete class, the direction of the source-code dependency becomes independent of the direction of control flow. You can point any dependency at an abstraction and swap implementations freely — making external systems (databases, APIs, UI frameworks, I/O devices) into plugins to your core logic. The main module decides which plugin to load. The core never knows and never cares.

The problem

Dispatching on a type string with if/elif forces every new output format to edit the same function. Adding "slack" or "webhook" means touching core logic — violating the Open-Closed Principle.

Bad

class ReportGenerator:
    def send(self, report: dict, output_type: str) -> None:
        if output_type == "pdf":
            print(f"[PDF] {report['title']}")
        elif output_type == "csv":
            print(f"[CSV] {report['title']}")
        elif output_type == "email":
            print(f"[EMAIL] {report['title']}")
        else:
            raise ValueError(f"Unknown: {output_type}")
class ReportGenerator {
  send(report: { title: string }, outputType: string): void {
    if (outputType === "pdf")        console.log(`[PDF] ${report.title}`);
    else if (outputType === "csv")   console.log(`[CSV] ${report.title}`);
    else if (outputType === "email") console.log(`[EMAIL] ${report.title}`);
    else throw new Error(`Unknown: ${outputType}`);
  }
}

The solution

An abstract notifier interface makes each output format a plugin. The core generator never changes — only a new class is added when a new format is needed.

Good

from abc import ABC, abstractmethod

class ReportNotifier(ABC):
    @abstractmethod
    def send(self, report: dict) -> None: ...

class PdfNotifier(ReportNotifier):
    def send(self, r): print(f"[PDF] {r['title']}")

class CsvNotifier(ReportNotifier):
    def send(self, r): print(f"[CSV] {r['title']}")

class EmailNotifier(ReportNotifier):
    def send(self, r): print(f"[EMAIL] {r['title']}")

class ReportGenerator:
    def __init__(self, notifier: ReportNotifier):
        self._notifier = notifier

    def send(self, report: dict) -> None:
        self._notifier.send(report)

gen = ReportGenerator(PdfNotifier())
gen.send({"title": "Q1 Sales"})
interface ReportNotifier {
  send(report: { title: string }): void;
}

class PdfNotifier   implements ReportNotifier {
  send(r: { title: string }) { console.log(`[PDF] ${r.title}`); }
}
class CsvNotifier   implements ReportNotifier {
  send(r: { title: string }) { console.log(`[CSV] ${r.title}`); }
}
class EmailNotifier implements ReportNotifier {
  send(r: { title: string }) { console.log(`[EMAIL] ${r.title}`); }
}

class ReportGenerator {
  constructor(private notifier: ReportNotifier) {}
  send(report: { title: string }): void { this.notifier.send(report); }
}

const gen = new ReportGenerator(new PdfNotifier());
gen.send({ title: "Q1 Sales" });

Key takeaway

Polymorphism's architectural superpower is not inheritance — it is the ability to make any external system a swappable plugin, leaving core business logic permanently unchanged.

Done with this lesson?

Mark it complete to earn XP and track your progress.