Skip to content
AgileLogics
6 min read

Best Practices for Production Clojure

Prerequisites: Testing with clojure.test, clojure.spec

Clojure rewards a particular way of structuring programs. These habits are common to well-run Clojure code bases.

Functional core, imperative shell

Keep business logic in pure functions over plain data, and push side effects (database, HTTP, time, randomness) to a thin outer layer. Pure functions are trivial to test and to try at the REPL.

;; Pure core: decisions as data
(defn plan-restock [inventory {:keys [threshold order-qty]}]
  (for [[item qty] (sort inventory)
        :when (< qty threshold)]
    {:action :order :item item :qty order-qty}))

(defn apply-deliveries [inventory deliveries]
  (reduce (fn [inv {:keys [item qty]}] (update inv item (fnil + 0) qty))
          inventory
          deliveries))

;; Imperative shell: the only place with side effects
(defn execute-plan! [state-atom settings]
  (let [plan (plan-restock @state-atom settings)]
    (doseq [{:keys [item qty]} plan]
      (println "ordering" qty "x" (name item)))       ; would call an API
    (swap! state-atom apply-deliveries plan)))

(def stock (atom {:apples 2 :bread 12 :milk 1}))
(execute-plan! stock {:threshold 5 :order-qty 10})
(println @stock)
ordering 10 x apples
ordering 10 x milk
{:apples 12, :bread 12, :milk 11}

plan-restock returns what should happen as data; only execute-plan! makes it happen. Testing the core needs no mocks — just call it with a map and compare the result.

Data design

  • Prefer maps with namespaced keywords (:order/id) for domain data; they're self-describing and don't collide.
  • Validate data at system boundaries (HTTP input, config, messages) with spec or Malli — not everywhere.
  • Keep data flat enough to be easy to navigate; use get-in/update-in paths when it isn't.

State and concurrency

  • A few atoms holding plain data; update them with pure functions.
  • Keep side effects out of swap! functions and dosync blocks (they may retry).
  • Manage stateful resources (servers, pools) with a lifecycle library, not global defs that connect at load time.

Code

  • Small functions with docstrings; namespaces organized by domain.
  • Threading macros over deep nesting; destructuring over repeated (:key m).
  • Functions before macros; macros only for syntax.
  • Turn on *warn-on-reflection*; run clj-kondo in CI.

Security

  • Read external data with clojure.edn/read-string, never clojure.core/read-string or eval.
  • Parameterized SQL (["... WHERE id = ?" id]), never string concatenation.
  • Secrets in environment variables; production REPLs bound to localhost and access-controlled.
  • Keep dependencies updated (clj -X:deps tree, antq to find outdated ones).

What's next

Try the Advanced Projects, then read What You Should Know Now — and What's Next for where to go from here.