Best Practices
Overview
Writing quality Clojure code requires embracing functional programming principles that guide everyday decisions. These best practices emerge from the Clojure community’s collective experience and help you write code that is simple, composable, and maintainable.
Functional Programming Principles
Embrace Immutability
Immutable data structures are the foundation of Clojure. Values never change after creation, eliminating entire classes of bugs related to unexpected state mutations.
Why it matters:
- Thread-safe by default without locks
- Enables structural sharing for efficiency
- Makes code easier to reason about
- Prevents defensive copying
- Supports time-travel debugging
Example:
;; ❌ Mutable state - not idiomatic
(def user-count (atom 0))
(swap! user-count inc)
(swap! user-count inc)
;; ✅ Immutable data - values never change
(def users [])
(def users-with-alice (conj users {:name "Alice"}))
(def users-with-bob (conj users-with-alice {:name "Bob"}))
;; Original value unchanged
users ;; => []
users-with-alice ;; => [{:name "Alice"}]
users-with-bob ;; => [{:name "Alice"} {:name "Bob"}]When to use atoms/refs:
- Coordinated state changes (refs with STM)
- Asynchronous updates (agents)
- Uncoordinated synchronous updates (atoms)
- Keep mutable state at system boundaries
Write Pure Functions
Pure functions return the same output for the same input and produce no side effects.
Why it matters:
- Easy to test (no setup or mocks needed)
- Easy to understand (behavior explicit in signature)
- Safe to parallelize
- Enables memoization and optimization
- Supports referential transparency
Example:
;; ❌ Impure - depends on external state
(def tax-rate 0.08)
(defn calculate-total [price]
(* price (+ 1 tax-rate))) ;; Depends on global state
;; ✅ Pure - all dependencies explicit
(defn calculate-total [price tax-rate]
(* price (+ 1 tax-rate)))
;; Usage
(calculate-total 100 0.08) ;; => 108.0
(calculate-total 100 0.10) ;; => 110.0
;; ❌ Impure - side effect
(defn save-user! [user]
(jdbc/insert! db :users user)
user)
;; ✅ Separate pure logic from effects
(defn prepare-user-data [user]
{:user/name (:name user)
:user/email (clojure.string/lower-case (:email user))
:user/created-at (java.time.Instant/now)})
(defn save-user! [db user-data]
(jdbc/insert! db :users user-data))
;; Compose at boundaries
(defn register-user! [db user]
(-> user
prepare-user-data
(->> (save-user! db))))Trade-offs:
- Push side effects to system boundaries
- Core business logic remains pure and testable
- I/O and mutation happen in thin shell layer
Prefer Small, Composable Functions
Build complex behavior by composing small, focused functions.
Why it matters:
- Each function does one thing well
- Easy to understand and test
- Reusable across contexts
- Follows Unix philosophy
- Enables bottom-up development
Example:
;; ❌ Large monolithic function
(defn process-orders [orders]
(let [valid-orders (filter #(and (pos? (:amount %))
(not (nil? (:customer-id %)))
(contains? % :items))
orders)
with-totals (map #(assoc % :total
(reduce + (map :price (:items %))))
valid-orders)
premium-only (filter #(= "premium" (:tier %)) with-totals)
sorted (sort-by :total > premium-only)]
sorted))
;; ✅ Small, composable functions
(defn valid-order? [order]
(and (pos? (:amount order))
(some? (:customer-id order))
(contains? order :items)))
(defn calculate-total [order]
(assoc order :total
(->> (:items order)
(map :price)
(reduce +))))
(defn premium? [order]
(= "premium" (:tier order)))
(defn by-total-desc [order1 order2]
(compare (:total order2) (:total order1)))
(defn process-orders [orders]
(->> orders
(filter valid-order?)
(map calculate-total)
(filter premium?)
(sort by-total-desc)))Data-Oriented Programming
Data First, Not Objects
Clojure programs manipulate plain data structures (maps, vectors, sets) rather than encapsulated objects.
Why it matters:
- Generic functions work on any data
- Data is self-describing (inspect in REPL)
- Easy serialization and deserialization
- Separation of data and behavior
- Enables data-driven development
Example:
;; ❌ Object-oriented thinking in Clojure
(defrecord User [name email]
Object
(toString [this]
(str "User: " name " <" email ">")))
(defn create-user [name email]
(User. name email))
;; ✅ Data-oriented - plain maps
(defn user [name email]
{:user/name name
:user/email email})
;; Generic functions work on any data
(defn format-user [user]
(str "User: " (:user/name user) " <" (:user/email user) ">"))
;; Extend behavior without modifying data structure
(defn active? [user]
(not (:user/deleted-at user)))
(defn premium? [user]
(= "premium" (:user/tier user)))
;; Composition
(->> users
(filter active?)
(filter premium?)
(map format-user))When to use defrecord:
- Performance-critical paths (faster field access)
- Protocol implementations
- Explicit type dispatch
- Most code works fine with plain maps
Use Namespaced Keywords
Qualify keywords with namespaces to prevent collisions and add context.
Why it matters:
- Prevents key collisions in large systems
- Self-documenting (shows domain)
- Supports spec validation
- Enables generic operations on qualified data
Example:
;; ❌ Unqualified keywords - collision risk
(def user {:id 1 :name "Alice" :email "alice@example.com"})
(def product {:id 1 :name "Widget" :price 9.99})
;; Merging causes collision
(merge user product) ;; => {:id 1, :name "Widget", :email "alice@example.com", :price 9.99}
;; ✅ Namespaced keywords - no collisions
(def user {:user/id 1 :user/name "Alice" :user/email "alice@example.com"})
(def product {:product/id 1 :product/name "Widget" :product/price 9.99})
(merge user product)
;; => {:user/id 1, :user/name "Alice", :user/email "alice@example.com",
;; :product/id 1, :product/name "Widget", :product/price 9.99}
;; Generic functions with namespaced keys
(defn entity-name [entity]
(or (:user/name entity)
(:product/name entity)
(:company/name entity)))REPL-Driven Development
Develop Interactively
Use the REPL to explore ideas, test functions, and build systems incrementally.
Why it matters:
- Immediate feedback loop
- Discover solutions through experimentation
- Test functions without full system restart
- Inspect live system state
- Debug in context
Example:
;; REPL workflow
;; 1. Start with data exploration
(def sample-data [{:name "Alice" :age 30}
{:name "Bob" :age 25}
{:name "Carol" :age 35}])
;; 2. Experiment with transformations
(map :name sample-data)
;; => ("Alice" "Bob" "Carol")
(filter #(> (:age %) 28) sample-data)
;; => ({:name "Alice" :age 30} {:name "Carol" :age 35})
;; 3. Build up solution incrementally
(defn adults [people]
(filter #(>= (:age %) 18) people))
(defn seniors [people]
(filter #(>= (:age %) 65) people))
;; 4. Test immediately
(adults sample-data)
(seniors sample-data)
;; 5. Compose
(defn senior-names [people]
(->> people
seniors
(map :name)))REPL best practices:
- Keep functions pure for easy testing
- Load namespace with
(require '[myapp.core :as core] :reload) - Use
commentblocks for REPL experiments - Rich comment blocks preserve exploration history
(comment
;; Exploration and examples
(def test-user {:user/name "Alice" :user/email "alice@example.com"})
(format-user test-user)
;; => "User: Alice <alice@example.com>"
(active? test-user)
;; => true
;; Try different scenarios
(active? (assoc test-user :user/deleted-at (java.time.Instant/now)))
;; => false
)Threading Macros for Clarity
Use -> and ->> for Pipeline Transformations
Threading macros make data transformations read like prose.
Why it matters:
- Eliminates nested parentheses
- Reads left-to-right, top-to-bottom
- Makes data flow explicit
- Easier to add/remove steps
Example:
;; ❌ Nested composition - hard to read
(defn process-text [text]
(clojure.string/join " "
(map clojure.string/capitalize
(filter #(> (count %) 3)
(clojure.string/split text #"\s+")))))
;; ✅ Thread-last ->> - reads naturally
(defn process-text [text]
(->> (clojure.string/split text #"\s+")
(filter #(> (count %) 3))
(map clojure.string/capitalize)
(clojure.string/join " ")))
;; Thread-first -> for object-like APIs
(defn update-user [user]
(-> user
(assoc :updated-at (java.time.Instant/now))
(update :login-count inc)
(dissoc :temporary-token)))
;; Combined threading
(defn process-orders [orders]
(->> orders
(filter valid-order?)
(map calculate-total)
(group-by :customer-id)
(reduce-kv (fn [acc customer-id customer-orders]
(assoc acc customer-id
(-> customer-orders
(sort-by :created-at)
first)))
{})))When to use each:
->(thread-first): Result becomes first argument (object-like:(.method obj arg))->>(thread-last): Result becomes last argument (collection functions:(map f coll))as->: Complex threading with named intermediate valuescond->/cond->>: Conditional threading
Destructuring for Clarity
Extract Values Declaratively
Destructuring extracts values from data structures inline.
Why it matters:
- Eliminates manual access code
- Documents expected shape
- More concise and readable
- Catches missing keys early
Example:
;; ❌ Manual access - verbose
(defn format-address [address]
(let [street (:street address)
city (:city address)
state (:state address)
zip (:zip address)]
(str street ", " city ", " state " " zip)))
;; ✅ Map destructuring - concise
(defn format-address [{:keys [street city state zip]}]
(str street ", " city ", " state " " zip))
;; Nested destructuring
(defn format-user-address [user]
(let [{{:keys [street city]} :address} user]
(str street ", " city)))
;; Vector destructuring
(defn process-point [[x y]]
{:x x :y y :distance (Math/sqrt (+ (* x x) (* y y)))})
(process-point [3 4])
;; => {:x 3, :y 4, :distance 5.0}
;; With defaults
(defn greet [{:keys [name title] :or {title "Guest"}}]
(str "Hello, " title " " name))
(greet {:name "Alice" :title "Dr."})
;; => "Hello, Dr. Alice"
(greet {:name "Bob"})
;; => "Hello, Guest Bob"Leverage Sequence Abstractions
Work with Lazy Sequences
Clojure sequences are lazy by default, computing values only when needed.
Why it matters:
- Process infinite sequences
- Avoid unnecessary computation
- Compose transformations efficiently
- Separate generation from consumption
Example:
;; Infinite sequences
(def fibonacci
((fn fib [a b]
(lazy-seq (cons a (fib b (+ a b)))))
0 1))
(take 10 fibonacci)
;; => (0 1 1 2 3 5 8 13 21 34)
;; Lazy transformations
(defn process-large-file [filename]
(->> (clojure.java.io/reader filename)
line-seq
(filter #(clojure.string/includes? % "ERROR"))
(map parse-error-line)
(take 100))) ;; Only processes until 100 found
;; Efficient pipeline
(defn top-customers [orders]
(->> orders
(filter #(> (:total %) 1000))
(group-by :customer-id)
(map (fn [[customer-id customer-orders]]
{:customer-id customer-id
:total-spent (reduce + (map :total customer-orders))}))
(sort-by :total-spent >)
(take 10))) ;; Stops after finding top 10When to force evaluation:
- Use
doallwhen side effects needed immediately - Use
dorunfor side effects without keeping results - Most of the time, let laziness work for you
Error Handling
Use ex-info for Context-Rich Exceptions
Create informative exceptions with ex-info and structured data.
Why it matters:
- Exceptions carry structured context
- Easier to debug with full context
- Programmatic error handling
- Standardized error format
Example:
;; ❌ String exceptions - no context
(defn withdraw [account amount]
(if (< (:balance account) amount)
(throw (Exception. "Insufficient funds"))
(update account :balance - amount)))
;; ✅ ex-info - structured context
(defn withdraw [account amount]
(if (< (:balance account) amount)
(throw (ex-info "Insufficient funds"
{:type :insufficient-funds
:account-id (:id account)
:balance (:balance account)
:requested amount}))
(update account :balance - amount)))
;; Catch and handle programmatically
(try
(withdraw account 1000)
(catch clojure.lang.ExceptionInfo e
(let [data (ex-data e)]
(if (= :insufficient-funds (:type data))
{:error "Cannot withdraw more than balance"
:details data}
(throw e)))))Spec for Validation
Define Specs for Data Shapes
Use clojure.spec to document and validate data structures.
Why it matters:
- Documents expected data shapes
- Runtime validation
- Generative testing
- Better error messages
- IDE support
Example:
(require '[clojure.spec.alpha :as s])
;; Define specs
(s/def ::name string?)
(s/def ::email (s/and string? #(re-matches #".+@.+\..+" %)))
(s/def ::age (s/and int? #(>= % 0) #(<= % 150)))
(s/def ::user (s/keys :req [::name ::email]
:opt [::age]))
;; Validate data
(s/valid? ::user {::name "Alice" ::email "alice@example.com"})
;; => true
(s/valid? ::user {::name "Bob" ::email "invalid"})
;; => false
(s/explain ::user {::name "Bob" ::email "invalid"})
;; Prints detailed error message
;; Function specs
(s/fdef create-user
:args (s/cat :name ::name :email ::email)
:ret ::user)
(defn create-user [name email]
{::name name ::email email})
;; Generate test data
(s/exercise ::user 3)
;; Generates 3 valid user mapsPerformance Considerations
Choose Right Data Structure
Select data structures based on access patterns.
Why it matters:
- Maps for key-based lookup (O(log32 n))
- Vectors for indexed access (O(log32 n))
- Sets for membership tests (O(log32 n))
- Lists for sequential processing (O(n))
Example:
;; ❌ Wrong structure - linear search
(def users-list '({:id 1 :name "Alice"}
{:id 2 :name "Bob"}))
(defn find-user [id]
(first (filter #(= id (:id %)) users-list))) ;; O(n)
;; ✅ Right structure - direct lookup
(def users-map {1 {:id 1 :name "Alice"}
2 {:id 2 :name "Bob"}})
(defn find-user [id]
(get users-map id)) ;; O(log32 n) ≈ O(1)
;; Transients for batch updates
(defn build-lookup-table [items]
(persistent!
(reduce (fn [acc item]
(assoc! acc (:id item) item))
(transient {})
items)))Use Transducers for Efficient Pipelines
Transducers compose transformations without creating intermediate collections.
Why it matters:
- Single pass instead of multiple passes
- Reduced memory allocation
- Works with any reducing context
- Faster for large datasets
Example:
;; ❌ Multiple passes - creates intermediate collections
(defn process-numbers [numbers]
(->> numbers
(map inc)
(filter even?)
(map #(* % %))))
;; ✅ Transducers - single pass
(defn process-numbers [numbers]
(into []
(comp (map inc)
(filter even?)
(map #(* % %)))
numbers))
;; Works with different reducing contexts
(def xf (comp (map inc)
(filter even?)
(map #(* % %))))
;; Into vector
(into [] xf (range 10))
;; Into set
(into #{} xf (range 10))
;; Reduce directly
(transduce xf + (range 10))Code Organization
One Namespace, One Purpose
Organize code into focused namespaces with clear responsibilities.
Why it matters:
- Easy to locate functionality
- Prevents circular dependencies
- Supports parallel development
- Clear module boundaries
Example:
;; ❌ Kitchen sink namespace
(ns myapp.core
(:require [clojure.java.jdbc :as jdbc]
[ring.adapter.jetty :as jetty]
[hiccup.core :as hiccup]))
(defn db-config [] {...})
(defn query-users [] {...})
(defn format-user-html [] {...})
(defn start-server [] {...})
;; ✅ Focused namespaces
;; myapp.db - Database access
(ns myapp.db
(:require [clojure.java.jdbc :as jdbc]))
(defn config [] {...})
(defn query-users [] {...})
;; myapp.views - HTML rendering
(ns myapp.views
(:require [hiccup.core :as hiccup]))
(defn user-page [user] {...})
;; myapp.server - HTTP server
(ns myapp.server
(:require [ring.adapter.jetty :as jetty]
[myapp.db :as db]
[myapp.views :as views]))
(defn handler [request] {...})
(defn start [] (jetty/run-jetty handler {...}))Testing Best Practices
Test Pure Functions Directly
Pure functions are trivial to test - no setup or teardown needed.
Example:
(ns myapp.core-test
(:require [clojure.test :refer [deftest is testing]]
[myapp.core :as core]))
;; ✅ Pure function - easy to test
(deftest test-calculate-discount
(testing "10% discount for orders over 100"
(is (= 90.0 (core/calculate-discount 100 0.1))))
(testing "No discount for zero rate"
(is (= 50.0 (core/calculate-discount 50 0.0))))
(testing "Multiple discounts stack"
(is (= 80.0 (core/calculate-discount 100 0.2)))))
;; Generative testing with spec
(require '[clojure.spec.test.alpha :as stest])
(stest/check `core/calculate-discount)
;; Generates random inputs and checks against specSummary
Quality Clojure code emerges from embracing functional programming principles and the language’s philosophy of simplicity. Immutability eliminates state-related bugs while persistent data structures provide efficiency through structural sharing. Pure functions make your code predictable, testable, and composable.
Data-oriented programming with plain maps and vectors creates flexible systems that are easy to inspect and transform. Namespaced keywords prevent collisions while adding self-documenting context. The REPL becomes your primary development tool, enabling rapid experimentation and incremental building.
Threading macros transform nested function calls into readable pipelines that flow naturally. Destructuring eliminates boilerplate while documenting expected data shapes inline. Lazy sequences enable processing infinite data and avoid unnecessary computation.
Structured error handling with ex-info provides programmatic access to error context. Specs document data shapes while enabling validation and generative testing. Right-sizing your use of transients and transducers optimizes hot paths without sacrificing simplicity.
These practices compound their benefits over time, creating codebases that remain maintainable and enjoyable to work with as they grow.
Related Content
Explanations:
- Common Clojure Anti-Patterns - Avoid common mistakes
- Programming Language Content Standard - Content guidelines
Tutorials:
- Clojure Beginner Tutorial - Core language features
- Clojure Intermediate Tutorial - Functional programming patterns
- Clojure Advanced Tutorial - Advanced techniques
Reference:
- Clojure Cheat Sheet - Quick syntax reference
- Clojure Glossary - Terminology guide