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.