DEV Community

Cover image for Clojure Is Awesome!!! [PART 13]
André Borba
André Borba

Posted on

Clojure Is Awesome!!! [PART 13]

Understanding Protocols and Records in Clojure: A Deep Dive

Clojure is known for its powerful abstractions, and Protocols and Records are two essential tools that bring structure and efficiency to your code.

  • Protocols define behavior in a polymorphic way, enabling extensibility and separation of concerns.
  • Records provide a way to define structured, efficient data types that interoperate well with Java.

These features blend the flexibility of functional programming with the performance of statically defined structures, making them ideal for real-world applications like data modeling, caching systems, and more.

In this deep dive, we’ll explore how to effectively use Protocols and Records with practical examples and performance insights.

Protocols: Defining Behavioral Contracts

In Clojure, Protocols define a set of related operations that multiple data types can implement.
Unlike traditional object-oriented interfaces, protocols allow you to extend behavior to existing types dynamically.

For example, let's define a simple storage contract:

(defprotocol DataStorage
  "Protocol for data storage operations"
  (store [this data] "Stores data and returns a success/failure result")
  (retrieve [this id] "Retrieves data by ID")
  (delete [this id] "Removes data by ID")
  (update-data [this id new-data] "Updates existing data"))
Enter fullscreen mode Exit fullscreen mode

How Protocols Differ from Java Interfaces

  • Extensibility: Unlike Java interfaces, a protocol can be implemented for existing types without modifying them.
  • Performance: Protocol dispatch is optimized with direct function calls instead of instanceof checks.

Implementing a Protocol: Multiple Approaches

  1. Implementing a Protocol for an Existing Type We can extend java.util.HashMap to implement our DataStorage protocol:
(extend-protocol DataStorage
  java.util.HashMap
  (store [this data]
    (let [id (java.util.UUID/randomUUID)]
      (.put this id data)
      {:success true :id id}))
  (retrieve [this id]
    (.get this id))
  (delete [this id]
    (.remove this id)
    {:success true})
  (update-data [this id new-data]
    (.replace this id new-data)
    {:success true}))
Enter fullscreen mode Exit fullscreen mode

No need to modify HashMap, we can extend its behavior seamlessly.

  1. Implementing a Protocol for nil (Graceful Failures) This approach ensures that calls to an uninitialized storage don't throw exceptions:
(extend-protocol DataStorage
  nil
  (store [_ _] {:success false :error "Storage not initialized"})
  (retrieve [_ _] nil)
  (delete [_ _] {:success false :error "Storage not initialized"})
  (update-data [_ _ _] {:success false :error "Storage not initialized"}))
Enter fullscreen mode Exit fullscreen mode

✅ Avoids NullPointerException, making the system more robust.

  1. Using reify for Inline Implementations Sometimes, we need quick, temporary implementations of a protocol:
(def storage
  (reify DataStorage
    (store [_ data] (println "Storing:" data) {:success true})
    (retrieve [_ id] (println "Retrieving ID:" id) nil)))
Enter fullscreen mode Exit fullscreen mode

✅ reify is great for mock implementations or test doubles.

Records: Efficient Structured Data

While Clojure maps ({}) are great for representing data, records (defrecord) provide a structured alternative with better performance.

  • Fast field access (similar to Java object fields).
  • Implements IMap, so it behaves like a map.
  • Can implement protocols for added functionality.

Creating and Using Records

(defrecord User [id username email created-at]
  DataStorage
  (store [this _]
    {:success true :id (:id this)})
  (retrieve [this _] this)
  (delete [this _] {:success true})
  (update-data [this _ new-data]
    (merge this new-data)))
Enter fullscreen mode Exit fullscreen mode

We can create instances of User in two ways:

(def user1 (->User "123" "borba" "borba@email.com" (java.time.Instant/now)))
(def user2 (map->User {:id "456"
                       :username "alice"
                       :email "alice@email.com"
                       :created-at (java.time.Instant/now)}))
Enter fullscreen mode Exit fullscreen mode

✅ ->User enforces fixed field order.
✅ map->User provides more flexibility.

Performance Comparison: Maps vs. Records vs. Types

Feature map (Regular) defrecord deftype
Mutable? ❌ No ❌ No ✅ Yes
Implements IMap? ✅ Yes ✅ Yes ❌ No
Custom Methods? ❌ No ✅ Yes ✅ Yes
Performance 🟡 Médio 🟢 Alto 🔵 Máximo

Practical Example: Building a Cache System

Let's build a simple in-memory caching system using records and protocols:

(ns core)

(defprotocol CacheOperations
  "Protocol defining cache operations"
  (cache-put [this k v ttl] "Store value with TTL (milliseconds)")
  (cache-fetch [this k] "Retrieve value if not expired")
  (cache-invalidate [this k] "Invalidate cache entry")
  (cache-clear [this] "Clear all entries")
  (cache-stats [this] "Return cache statistics"))

(defrecord InMemoryCache [storage stats-atom]
  CacheOperations
  (cache-put [_ k v ttl]
    (swap! storage assoc k {:value v
                            :ttl ttl
                            :timestamp (java.time.Instant/now)})
    (swap! stats-atom update :puts inc)
    v)

  (cache-fetch [this k]
    (when-let [entry (get @storage k)]
      (let [now (java.time.Instant/now)
            elapsed (java.time.Duration/between (:timestamp entry) now)
            millis-elapsed (.toMillis elapsed)]
        (if (< millis-elapsed (:ttl entry))
          (do
            (swap! stats-atom update :hits inc)
            (:value entry))
          (do
            (cache-invalidate this k)
            (swap! stats-atom update :misses inc)
            nil)))))

  (cache-invalidate [_ k]
    (swap! storage dissoc k)
    (swap! stats-atom update :removes inc)
    nil)

  (cache-clear [_]
    (reset! storage {})
    (swap! stats-atom update :clears inc)
    nil)

  (cache-stats [_]
    @stats-atom))

(defn create-cache []
  (->InMemoryCache (atom {}) 
                   (atom {:puts 0 :hits 0 :misses 0 :removes 0 :clears 0})))
Enter fullscreen mode Exit fullscreen mode

✅ Uses Protocols for extensibility
✅ Uses Records for efficiency
✅ Implements TTL expiration logic

Best Practices: When to Use What?

  1. Use Protocols when you need behavior polymorphism.
  2. Use Records when you need efficient, structured data.
  3. Use Maps when you need flexibility over structure.
  4. Use deftype for performance-critical code with mutable fields.

Conclusion

Protocols and Records provide a powerful mechanism for defining behavior and structuring data efficiently in Clojure.
By using Protocols for extensibility and Records for performance, we can build clean, maintainable, and scalable applications.

Top comments (0)

Some comments may only be visible to logged-in visitors. Sign in to view all comments. Some comments have been hidden by the post's author - find out more