The Visitor Pattern Is a Lie
The case for ditching the pattern that breaks the Open-Closed Principle
You’ve been lied to.
All those design patterns, visitor classes, abstract base methods, and factory hierarchies you were told to write? They weren’t extensible. They were a workaround for something your language was missing.
Python gives you many tools — functions, classes, pattern matching, even some runtime magic — but when it comes to structuring logic around behavior, it quietly lets you down.
You want to say: “When I get this kind of input, do that.”
Not: “Subclass this, override that, and hope no one breaks the inheritance chain.”
And not to think about the tests for testing this.
So you write if isinstance(...)
, or abuse __class__
, or invent a Visitor pattern with accept(self)
and a registry of dispatch methods. You build pyramids of logic just to avoid a switch-case
.
But what if you didn’t need to?
This series is about a different approach — one where you let types do the dispatching, behaviors live in one place, and your code stays open to extension without becoming a maze of conditionals and override chains.
In short: you’ll learn how to flatten your logic, stop worshipping patterns, and finally write Python that’s both simpler and more powerful.
Part 1: Beyond If-Else Hell in Python
Or: Why multiple dispatch beats conditionals and class methods for clean, extensible code
There’s a special kind of dread that creeps in as your if-elif chain grows longer.
def process(x):
if isinstance(x, int):
return x + 1
elif isinstance(x, float):
return round(x)
elif isinstance(x, str):
return x.upper()
else:
raise TypeError("Unsupported type")
It starts small. Innocent, even. But soon it spreads across your codebase like kudzu, tangling logic and mocking maintainability.
Most developers know this is ugly. Some reach for object-oriented abstractions: maybe a base class, some polymorphic methods. Others write visitors, or fake function dispatch with dictionaries. You start building tools to manage your tools. And suddenly, the thing you were trying to avoid — complexity — becomes the entire architecture.
Let’s stop pretending this is necessary.
Say hello to multiple dispatch
What if your code could look like this:
from plum import dispatch
@dispatch
def process(x: int):
return x + 1
@dispatch
def process(x: float):
return round(x)
@dispatch
def process(x: str):
return x.upper()
Call process(2)
, process(3.14)
, or process("hello")
, and the right function fires.
No isinstance
. No base classes. No method lookups. Just pure behavior, tied to types.
Real-world case: JSON encoding
Let’s say you’re building a custom JSON encoder. Here’s how most people start:
def to_json(obj):
if isinstance(obj, int):
return str(obj)
elif isinstance(obj, str):
return f'"{obj}"'
elif isinstance(obj, list):
return '[' + ', '.join(to_json(x) for x in obj) + ']'
elif isinstance(obj, dict):
return '{' + ', '.join(f'"{k}": {to_json(v)}' for k, v in obj.items()) + '}'
else:
raise TypeError("Unsupported type")
This is serviceable. Until you want to support:
- datetime
- Decimal
- Custom dataclasses
And now you have 20 conditionals across three files. Let alone the tests you wrote which you now have to modify - in both cases violating the open closed principle!
The dispatch rewrite
from plum import dispatch
@dispatch
def to_json(x: int):
return str(x)
@dispatch
def to_json(x: str):
return f'"{x}"'
@dispatch
def to_json(x: list):
return '[' + ', '.join(to_json(item) for item in x) + ']'
@dispatch
def to_json(x: dict):
return '{' + ', '.join(f'"{k}": {to_json(v)}' for k, v in x.items()) + '}'
To extend it? Just add:
from datetime import datetime
@dispatch
def to_json(x: datetime):
return f'"{x.isoformat()}"'
And in the same way you add a small test for the new function.
Done. No need to touch existing logic. No risk of breaking anything else. The new behavior is fully modular and supports the open closed principle!
Why this matters
You may not need multiple dispatch every day. But when you do, it’s like opening a window in a room you thought had none.
It’s ideal for:
- Recursive tree walking (ASTs, math expressions)
- Serialization, transpilation, formatting
- Rule engines
- Plugin systems
- Domain-specific language interpreters
And it’s available in Python today. You don’t need to learn Julia or Common Lisp right now.
Part 2: The Open-Closed Principle Without Inheritance
Or: How multiple dispatch lets you extend functionality without changing existing code
You’ve probably heard the Open-Closed Principle (OCP) quoted before: “Software entities should be open for extension, but closed for modification.” It sounds elegant, even obvious. But when it comes time to follow it in real life, most developers give up and just modify their classes anyway.
Why? Because the classical Single Dispatch OOP class system is designed exactly contrary to this principle! Again: They lied to you!
In classical object-oriented programming (the Java/C++ way), the standard path to OCP is inheritance. You subclass a base class. You override methods. Maybe you add a new visit_X to your visitor. It works… until it doesn’t. Because the more you subclass, the more brittle everything gets. Add a new operation? You are forced to edit an abstract base class and every implementation. Extend behavior? You’re forced to touch code and rewrite classes you promised you’d never change.
What if Python could do better?
With multiple dispatch, it can.
It follows the Common Lisp Object System (CLOS) designed already back in the 70-ies for the ancient language Common Lisp. The developers writing Common Lisp where one of the smartest in the field. Julia overtook its Multiple Dispatch system from Common Lisp, since it is a Lisp dialect.
Instead of binding behavior to classes and relying on virtual methods, you bind behavior to types — and let the dispatch system figure out what to run. No need to subclass. No need to modify anything - especially not if-else branches. You just add a new function, and the system knows what to do.
Let’s make it concrete.
A Tree, a Visitor, and Too Much Coupling
Let’s say you’re writing an arithmetic evaluator with an abstract syntax tree. You want to evaluate expressions like this:
Add(Number(2), Multiply(Number(3), Number(4)))
The Visitor Pattern
To evaluate this, you’d normally build a base class and a visitor:
from abc import ABC, abstractmethod
class Expr(ABC):
@abstractmethod
def accept(self, visitor):
pass
class Number(Expr):
def __init__(self, value):
self.value = value
def accept(self, visitor):
return visitor.visit_number(self)
class Add(Expr):
def __init__(self, left, right):
self.left = left
self.right = right
def accept(self, visitor):
return visitor.visit_add(self)
class Multiply(Expr):
def __init__(self, left, right):
self.left = left
self.right = right
def accept(self, visitor):
return visitor.visit_multiply(self)
Now the visitor:
class Visitor(ABC):
@abstractmethod
def visit_number(self, node): pass
@abstractmethod
def visit_add(self, node): pass
@abstractmethod
def visit_multiply(self, node): pass
class Evaluator(Visitor):
def visit_number(self, node):
return node.value
def visit_add(self, node):
return node.left.accept(self) + node.right.accept(self)
def visit_multiply(self, node):
return node.left.accept(self) * node.right.accept(self)
To add a new node (like Negate
)?
- Add a new class
- Add a new method to Visitor
- Add a new method to Evaluator
- Modify three different places
- And mofidy their tests, too
That is not the Open-Closed Principle.
The plum-dispatch Way
Now let’s rewrite the same thing using multiple dispatch.
from dataclasses import dataclass
from plum import dispatch
@dataclass
class Number:
value: int
@dataclass
class Add:
left: any
right: any
@dataclass
class Multiply:
left: any
right: any
Evaluation logic:
@dispatch
def evaluate(x: Number):
return x.value
@dispatch
def evaluate(x: Add):
return evaluate(x.left) + evaluate(x.right)
@dispatch
def evaluate(x: Multiply):
return evaluate(x.left) * evaluate(x.right)
To add Negate
:
@dataclass
class Negate:
value: any
@dispatch
def evaluate(x: Negate):
return -evaluate(x.value)
You didn’t touch anything else. That’s real OCP.
(This example is using only one argument for dispatch, so it is actually a single dispatch example. But already here, you can see the huge advantage which this way of multiple dispatch - with a single argument - brings you.)
Benefits in Practice
- No interfaces or abstract classes needed (reduces code!)
- No base classes or visitor scaffolding
- No modification of existing logic (and tests already written for them!)
- Just define a new function for a new type
- No method collisions or boilerplate
And because the evaluate function is modular, you can define multiple operations — evaluate
, to_latex
, optimize
, analyze
, etc. — all using the same tree, without cluttering your types.
Part 3: Making Python Smarter with Type-Driven Behavior
Or: How to build powerful systems that adapt to types instead of cluttering code with patterns
When you think of “smart” code, you probably think of clever abstractions, flexible logic, or maybe a pattern you read about once in the Gang of Four book. But real smartness often comes down to something simpler:
Let the types tell the truth.
Multiple dispatch is one of the rare features that lets your code adapt to the shape of your data. You don’t need factories, conditionals, or tangled class hierarchies. You just define what should happen when certain types appear, and the right thing happens automatically.
Let’s see that in action.
Case study: Expression Trees with Multiple Behaviors
Imagine you have a tiny language with an AST (the abstract syntax tree example from above):
@dataclass
class Number:
value: int
@dataclass
class Add:
left: any
right: any
@dataclass
class Multiply:
left: any
right: any
Now, we want to:
- Evaluate the expression
- Convert it to LaTeX
- Optimize constant subtrees
Let’s do all three with multiple dispatch.
1. Evaluation
@dispatch
def evaluate(x: Number):
return x.value
@dispatch
def evaluate(x: Add):
return evaluate(x.left) + evaluate(x.right)
@dispatch
def evaluate(x: Multiply):
return evaluate(x.left) * evaluate(x.right)
2. To LaTeX
@dispatch
def to_latex(x: Number):
return str(x.value)
@dispatch
def to_latex(x: Add):
return f"({to_latex(x.left)} + {to_latex(x.right)})"
@dispatch
def to_latex(x: Multiply):
return f"{to_latex(x.left)} \\cdot {to_latex(x.right)}"
3. Optimization
@dispatch
def optimize(x: Number):
return x
@dispatch
def optimize(x: Add):
left = optimize(x.left)
right = optimize(x.right)
if isinstance(left, Number) and isinstance(right, Number):
return Number(left.value + right.value)
return Add(left, right)
@dispatch
def optimize(x: Multiply):
left = optimize(x.left)
right = optimize(x.right)
if isinstance(left, Number) and isinstance(right, Number):
return Number(left.value * right.value)
return Multiply(left, right)
Now you can mix and match behaviors:
tree = Add(Number(2), Multiply(Number(3), Number(4)))
optimized = optimize(tree)
print(evaluate(optimized)) # 14
print(to_latex(optimized)) # "(2 + 12)"
The pattern that emerges
Each behavior is a flat, type-indexed namespace of functionality. No base class knows or cares how it’s used. Each function handles what it should, and nothing more.
You didn’t need:
- Inheritance
- Pattern matching
- Visitor boilerplate
- Conditional trees
You got extensibility, clarity, and power — with fewer moving parts.
Real-World Uses
Remember again: you can use this pattern anywhere you’d normally need type checks or visitor patterns:
- AST processing in compilers or interpreters
- Serializers and formatters
- Machine learning pipelines where behavior depends on data shape
- Scientific computing where symbolic manipulation is common
- Rule engines
Anywhere your code wants to say: if it’s this type, do that — this is your tool.
What we've learend so far
Multiple dispatch turns your code into a system that adapts to the world, not one that resists it. It pushes complexity into the right dimension: behavior by type.
Python isn’t known for this feature. But thanks to libraries like plum-dispatch
, it’s just a few imports away.
No frameworks. No inheritance trees. Just pure, expressive, intelligent Python.
The ideas go deep. But the code stays clean.
Final Part: Building a Type-Driven Plugin System in Python
Or: how to scale functionality with multiple dispatch and zero boilerplate
Plugins are deceptively hard. The moment you want extensibility, you usually end up with:
- A plugin registry
- Custom decorators
- Reflection hacks
- Base classes with fragile methods
In the worst cases, you design a micro-framework just to manage extensions. All you wanted was to inject some behavior based on types.
Let’s make that simple.
Multiple dispatch is a plugin system — if you use it right.
Scenario: A Rule Engine for Discounting
Imagine you’re writing a discount engine for an online store. You want to apply rules based on the customer, the product, and maybe the region.
You could write this:
def discount(customer, product, region):
if customer.vip and region == "EU" and product.category == "Books":
return 0.20
elif ...:
...
But that logic becomes impossible to extend across teams. Each time you add a rule, you modify the core logic - violating the OCP. It’s a trap.
The plum-dispatch approach
from dataclasses import dataclass
from plum import dispatch
@dataclass
class Customer:
vip: bool
@dataclass
class Product:
category: str
@dataclass
class Region:
name: str
Now dispatch by triples:
@dispatch
def discount(c: Customer, p: Product, r: Region):
return 0.0 # default: no discount
Add rules as plugins:
@dispatch
def discount(c: Customer, p: Product, r: Region)
if c.vip and p.category == "Books" and r.name == "EU":
return 0.20
return 0.0
Better yet: allow modules to register their own rules. You don’t need to modify existing logic. Each team or module just defines more @dispatch
overloads.
To extend behavior, you write code. You don’t edit code.
Can you add and add new rules like plugins?
Yes, that’s exactly what plum-dispatch allows: defining new overloads of the same function in different modules or scopes.
You can define rules like this, even across different files:
# module_a.py
@dispatch
def discount(c: Customer, p: Product, r: Region):
if c.vip and p.category == "Books" and r.name == "EU":
return 0.20
return 0.0
# module_b.py
@dispatch
def discount(c: Customer, p: Product, r: Region):
if not c.vip and p.category == "Books":
return 0.10
return 0.0
But here’s the catch: this will not behave as multiple “overloads”. Only one overload of (Customer, Product, Region) will be active — the last one registered wins, because the type signature is the same.
Why? Because plum-dispatch
dispatches by type only, not by value (predicates).
- You cannot dispatch based on attribute values (like
c.vip == True
) directly. - All
@dispatch
overloads must differ by type signature — not internal logic or condition.
Workaround 1: Use Value Wrapping Types
If you want dispatching by VIP vs non-VIP, you could wrap those concepts into types:
@dataclass
class VIPCustomer(Customer): pass
@dataclass
class RegularCustomer(Customer): pass
Then dispatch by type:
@dispatch
def discount(c: VIPCustomer, p: Product, r: Region):
return 0.2
@dispatch
def discount(c: RegularCustomer, p: Product, r: Region):
return 0.0
This is close to “pattern matching by tags” — but you must promote your data to more expressive types beforehand.
Workaround 2: Use a Rule Engine + Dispatch
Let discount()
handle type-based routing. Then inside, maintain a list of predicate-based rule functions:
discount_rules = []
def rule(func):
discount_rules.append(func)
return func
Then dispatch once:
@dispatch
def discount(c: Customer, p: Product, r: Region):
for rule_func in discount_rules:
result = rule_func(c, p, r)
if result is not None:
return result
return 0.0
# This is just an example function. The details you have to work out
# according to your "businesslogic"
Now plugins can register predicate rules like this:
@rule
def vip_books_eu(c, p, r):
if c.vip and p.category == "Books" and r.name == "EU":
return 0.20
@rule
def nonvip_books_global(c, p, r):
if not c.vip and p.category == "Books":
return 0.10
This gives you true plugin extensibility — just not via plum-dispatch alone.
(Modify the rule_func
according to your needs. Think about precedence and what overwrites what. Or think about how to apply dictionaries to pick the right function.)
Multiple arguments: the real unlock
Multiple dispatch isn’t just about replacing isinstance
. It’s about dispatching on combinations of types.
For example:
@dispatch
def combine(a: list, b: str):
return a + [b]
@dispatch
def combine(a: str, b: list):
return [a] + b
No need for type(a) == logic
. The type system decides. You just describe what happens.
This is where multiple dispatch starts to feel like a domain-specific language engine. You define semantics by type. It behaves accordingly.
Real-World Use Cases
- Rule engines
- Game engines (e.g., action resolution based on object + weapon + environment)
- Simulation systems
- Configuration loaders
- Plugin-based transformations (e.g., data processing pipelines)
Anywhere you want combination logic or decentralized extensibility, this shines.
Wrap-Up
You don’t need to build a plugin framework. You don’t need to simulate dynamic dispatch. You just need the right dispatch system.
plum-dispatch
lets you:
- Write logic modularly
- Compose behaviors by type
- Extend systems without modifying core code
It’s the cleanest plugin system you’ll never need to document.
Thanks for reading the article!
Now go build something smarter. And cleaner.
Do you like this kind of thinking?
- Follow me on Medium: @gwangjinkim for deep dives on Python, Lisp, system design, and developer thinking, and much more
- Subscribe on Substack: gwangjinkim.substack.com — coming soon with early essays, experiments & newsletters (just getting started).
- Visit my Ghost blog (here): everyhub.org — with hands-on tech tutorials and tools (most of them I cross-post here in medium, but not all of them).
Follow anywhere that fits your style — or all three if you want front-row seats to what’s coming next.