Building Extensible Systems with Multimethods and Protocols: Escaping the Function Maze
Remember that time your “just functions” approach turned into a cond-monster? That was me, lost in a function maze—until I finally took the polymorphism plunge.

I'll admit it: I spent way too long avoiding polymorphism in Clojure.
Every time I encountered a problem that screamed for multimethods or protocols, I'd freeze. Analysis paralysis would kick in hard. "Should I use a multimethod? A protocol? Maybe just... write another function?" Nine times out of ten, I'd chicken out and reach for the trusty cond
statement or scattered if
checks. Sure, it worked. Until it didn't.
You know that moment when you're three months into a project and realize a simple test would be trivial to write if you'd just structured your polymorphism correctly from the start? Or when adding a new data format means touching twenty different functions because you took the "just write functions" approach? Yeah, that was me. Constantly.
If you've been there too, this article is for us. Let's finally understand when and why to reach for Clojure's polymorphic tools, and more importantly, how to stop second-guessing ourselves.
The "Just Functions" Trap

Here's the thing about avoiding polymorphism: it works beautifully until it spectacularly doesn't. Let me show you what I mean with a real example that bit me hard.
I was building a data processing pipeline that needed to handle different file formats. Started simple enough:
(defn process-data [file-path]
(cond
(.endsWith file-path ".csv") (process-csv file-path)
(.endsWith file-path ".json") (process-json file-path)
(.endsWith file-path ".xml") (process-xml file-path)
:else (throw (ex-info "Unsupported format" {:file file-path}))))
Worked great! Then we needed to add Excel support. Easy enough, just another condition. Then Parquet files. Then custom binary formats. Then we needed different processing strategies for the same format based on the data source. Then...
You see where this is going. Within six months, that innocent cond
statement had grown into a 50-line monster that lived in three different namespaces and required a diagram to understand.
The real kicker? When we finally needed to write integration tests that mocked different data sources, we realized we'd have to duplicate all that logic or refactor everything anyway.
Why Polymorphism Actually Matters
Clojure eschews the traditional object-oriented approach of creating a new data type for each new situation, instead preferring to build a large library of functions on a small set of types. However, Clojure fully recognizes the value of runtime polymorphism in enabling flexible and extensible system architecture.
The core insight that finally clicked for me is this: polymorphism isn't about being fancy; it's about being extensible. It's the difference between a system that can grow and one that calcifies.
Think about it from the perspective of the Expression Problem. In traditional OOP, it's easy to add new data types (just create a new class), but adding new operations means touching every existing class. In functional programming, it's the reverse: easy to add new functions, but extending behavior for new types often means modifying existing functions.
Clojure's multimethods and protocols solve this elegantly. They let you:
- Add new types without touching existing code
- Add new operations without modifying type definitions
- Extend third-party types without monkey-patching
- Keep related behavior organized in coherent abstractions
The real magic happens when multiple people (or future you) can extend your system independently. That's what "open for extension, closed for modification" actually looks like in practice.
Understanding Multimethods: Dispatch on Anything
Using multimethods, you associate a name with multiple implementations by defining a dispatching function, which produces dispatching values that are used to determine which method to use. The dispatching function is like the host at a restaurant. The host will ask you questions like "Do you have a reservation?" and "Party size?" and then seat you accordingly.

Multimethods are Clojure's most flexible polymorphism tool. The key insight is that dispatch function - it can be anything that returns a value to dispatch on.
Let's revisit that data processing pipeline with multimethods:
(defmulti process-data
"Process data based on file extension and source"
(fn [file-path metadata]
[(.toLowerCase (last (str/split file-path #"\.")))
(:source metadata)]))
(defmethod process-data ["csv" "sales"] [file-path _]
(-> (slurp file-path)
(parse-csv)
(enrich-sales-data)))
(defmethod process-data ["csv" "inventory"] [file-path _]
(-> (slurp file-path)
(parse-csv)
(validate-inventory-format)))
(defmethod process-data ["json" :default] [file-path _]
(-> (slurp file-path)
(parse-json)
(normalize-json-structure)))
(defmethod process-data :default [file-path metadata]
(throw (ex-info "Unsupported combination"
{:file file-path :metadata metadata})))
Now adding support for a new format or source is just defining a new defmethod
. No existing code changes. Tests can easily mock specific combinations. And the dispatch logic is crystal clear.
The beauty is in that dispatch function. Want to dispatch on multiple arguments? Values in maps? The result of a computation? Clojure multimethods are not hard-wired to class/type, they can be based on any attribute of the arguments, on multiple arguments, can do validation of arguments and route to error-handling methods etc.
Protocols: When Type-Based Dispatch Shines
Approximately 93.58 percent of the time, you'll want to dispatch to methods according to an argument's type. For example, count needs to use a different method for vectors than it does for maps or for lists. Although it's possible to perform type dispatch with multimethods, protocols are optimized for type dispatch.

Protocols are what you reach for when you're dispatching on the type of the first argument and you want it to be fast. They're also great for grouping related functions together.
Here's a real example from a recent project where I needed different serialization strategies:
(defprotocol Serializable
"Convert data structures to wire format"
(to-wire [this format] "Serialize to specified wire format")
(from-wire [this data format] "Deserialize from wire format")
(wire-size [this format] "Estimate serialized size"))
(defrecord User [id email preferences]
Serializable
(to-wire [this format]
(case format
:json (json/encode this)
:edn (pr-str this)
:compact (encode-compact-user this)))
(from-wire [_ data format]
(case format
:json (map->User (json/decode data true))
:edn (read-string data)
:compact (decode-compact-user data)))
(wire-size [this format]
(case format
:json (* 1.2 (count (pr-str this))) ; JSON overhead
:edn (count (pr-str this))
:compact (* 0.6 (count (pr-str this))))))
;; Can extend existing types too
(extend-protocol Serializable
clojure.lang.PersistentVector
(to-wire [this format]
(case format
:json (json/encode this)
:edn (pr-str this)))
(from-wire [_ data format]
(case format
:json (json/decode data)
:edn (read-string data)))
(wire-size [this format]
(* 1.1 (count (pr-str this)))))
The killer feature here is being able to extend existing types. I can make built-in Clojure vectors work with my serialization protocol without touching their implementation. Try doing that cleanly with inheritance!
Performance: When It Actually Matters

Let's talk numbers. Protocols are about 5x faster than multimethod calls for this particular case of type-based dispatch. You can find older estimates of 100x or more for this difference but I think the 5x difference is a good rule of thumb for modern JVM and Clojure versions.
But here's the thing: However, with such a trivial function, execution is dominated by dispatch; in real situations the difference is unlikely to be meaningful.
The performance difference mostly matters when:
- You're doing type-based dispatch in a tight loop
- The actual work being done is minimal (so dispatch overhead dominates)
- You're building foundational libraries where every nanosecond counts
For most application code, the 5x difference won't be your bottleneck. I've seen way more performance problems from accidentally calling count
on lazy sequences or forgetting to use transients than from choosing multimethods over protocols.
That said, if you're doing type-based dispatch, protocols are usually the better choice anyway because they're more explicit about the relationship between types and behavior.
The Decision Tree That Finally Set Me Free
After years of analysis paralysis, here's the decision tree that finally liberated me:

Start with these questions:
- Am I dispatching on type of the first argument?
- Yes → Probably want protocols
- No → Definitely want multimethods
- Do I need to group multiple related functions together?
- Yes → Protocols (they're explicitly designed for this)
- No → Either works, but multimethods might be simpler
- Will I need to extend types I don't control?
- Yes → Both work, but protocols make the relationship more explicit
- No → Either works
- Is this a hot path where dispatch performance matters?
- Yes → Protocols win by ~5x for type dispatch
- No → Use whichever feels more natural
- Am I dispatching on complex combinations or computed values?
- Yes → Multimethods are your only choice
- No → Either works
use protocols when they are sufficient but if you need to dispatch based on the phase of the moon as seen from mars then multimethods are your best choice.
Real-World Examples: When Each Shines
Multimethods Excel When...
Complex routing logic:
(defmulti handle-request
(fn [request]
[(:method request)
(:route request)
(:user-role request)]))
(defmethod handle-request [:get "/api/users" :admin] [req]
(fetch-all-users))
(defmethod handle-request [:get "/api/users" :user] [req]
(fetch-current-user (:user req)))
(defmethod handle-request [:post "/api/users" :admin] [req]
(create-user (:body req)))
Event processing with hierarchies:
(derive ::error ::event)
(derive ::warning ::event)
(derive ::info ::event)
(defmulti log-event :level)
(defmethod log-event ::event [event]
(println "Base event processing:" event))
(defmethod log-event ::error [event]
(send-alert! event)
(log-to-error-tracking event))
Protocols Excel When...
Consistent interfaces across types:
(defprotocol Drawable
(draw [this context])
(bounds [this])
(hit-test [this point]))
(defrecord Rectangle [x y width height]
Drawable
(draw [this ctx]
(draw-rect ctx x y width height))
(bounds [this]
{:x x :y y :width width :height height})
(hit-test [this point]
(point-in-rect? point x y width height)))
(defrecord Circle [x y radius]
Drawable
(draw [this ctx]
(draw-circle ctx x y radius))
(bounds [this]
{:x (- x radius) :y (- y radius)
:width (* 2 radius) :height (* 2 radius)})
(hit-test [this point]
(< (distance point [x y]) radius)))
The Moment It All Clicked
The breakthrough for me came when I realized that good polymorphism isn't about the choice between multimethods and protocols. It's about recognizing when your code is crying out for extensibility.
Those scattered cond
statements? Giant case
expressions that keep growing? Functions with names like process-data-v2
and handle-special-case
? That's your codebase telling you it wants to grow, but you're forcing it into a rigid structure.
The beautiful thing about both multimethods and protocols is that they're additive. You define the abstraction once, then you and everyone else can extend it forever without touching the original code. That's not just good software engineering—it's liberating.
Your Next Steps
Here's my challenge to you: next time you catch yourself writing a cond
that dispatches on type or value, stop. Ask yourself:
- Will this need to grow?
- Will someone else need to extend this behavior?
- Am I creating a pattern that will repeat elsewhere?

If any answer is "yes" (or "maybe"), try implementing it with multimethods or protocols instead. Start simple—you can always refactor later, but now you'll have the tools to refactor toward extensibility instead of away from it.
The analysis paralysis that kept me stuck for so long wasn't really about choosing the right tool. It was about recognizing when I needed polymorphism at all. Once you develop that instinct, the choice between multimethods and protocols becomes much easier.
And remember: On the other hand, if you are a Clojure newbie and you think you need polymorphism, there's a good chance you're wrong. A genuine need for defining new polymorphic functionality is very rare in Clojure. But when you do need it, these tools will transform how your code grows and evolves.
Stop overthinking it. Start building systems that can surprise you with how naturally they extend. Your future self will thank you.