Blog › ICP guides
Clojure developer on retainer: persistent data structures, core.async, macros, and Datomic on monthly retainer
August 28, 2026 · ~21 min read
A fintech startup running a Clojure-based payment processing system had a reconciliation problem. Their payments.pipeline namespace consumed events from a Kafka topic, processed each through a sequence of validation and enrichment steps, and forwarded confirmed payments to a settlement service. Under normal load the pipeline behaved correctly. Under peak load — end-of-month batch submissions, payroll runs across enterprise clients — the reconciliation team reported that approximately 0.3% of payment events were missing from settlement reports. The events had been received by the Kafka consumer. They were simply disappearing somewhere inside the pipeline. The engineering team had spent three weeks adding log statements, adjusting Kafka consumer group offsets, and increasing JVM heap before engaging a Clojure consultant on monthly retainer.
The consultant’s REPL investigation in the first session identified the root cause within two hours: the pipeline was built around a single (chan) — a rendezvous channel with no buffer — fed by twelve concurrent producer threads using put!. The put! function on a full or rendezvous channel is non-blocking by design: it accepts a callback for when the put succeeds, but under sustained load the callback queue was overflowing. The existing five go blocks downstream were processing synchronous JDBC writes to PostgreSQL, making them I/O-bound and slow — precisely the wrong workload for go blocks, which occupy a fixed thread pool. The redesign replaced the rendezvous channel with a (chan 1000) bounded intake channel, switched producer threads from put! to >!! blocking puts for backpressure, migrated the CPU-bound validation step to pipeline-async with four parallelism and a normalization transducer, and added a (chan 500) dead-letter channel for validation failures with a dedicated consumer persisting failed events to PostgreSQL. Message loss dropped from 0.3% to zero. The entire redesign took 18 hours across four weeks.
Clojure developers, Clojure architects, and functional programming consultants on monthly retainer — fractional Clojure engineers, ClojureScript platform advisors, Datomic schema consultants — do their highest-value work in core.async topology design, persistent data structure architecture, macro system design, Datomic schema evolution, and clojure.spec authorship. None of this work produces a user-visible artifact proportional to the hours behind it. This guide covers the full technical depth of Clojure retainer engagements: persistent data structures, lazy sequences and transducers, clojure.spec, defmulti and protocols, concurrency primitives including atoms and STM, core.async channel design, macro writing, the Ring and Reitit and Pedestal web stacks, ClojureScript with shadow-cljs and Re-frame, and Datomic — and how to structure a Clojure developer retainer that makes the architectural hours behind each decision visible.
Clojure fundamentals
Clojure’s value proposition rests on four interlocking features: immutable persistent data structures that make shared state safe, a Lisp macro system that makes the language extensible, a hosted runtime (JVM and JavaScript) that provides ecosystem access, and a functional programming model that eliminates entire categories of bugs by making data transformation explicit. A Clojure architect on retainer applies these features at the system level: designing namespace hierarchies that respect domain boundaries, selecting data representations that make invalid states unrepresentable, and choosing between lazy sequences and transducers based on the throughput and composition requirements of each pipeline.
Persistent data structures
Clojure’s four core data structures — vectors, lists, maps, and sets — are all persistent: every “modification” returns a new structure that shares unchanged subtrees with the original (structural sharing), so the old version is never destroyed and neither operation requires copying the full structure. This makes persistence practical even for large data sets.
;; Persistent vectors — O(log32 n) indexed access, O(log32 n) update:
(def v1 [1 2 3 4 5])
(def v2 (conj v1 6)) ;; => [1 2 3 4 5 6] — v1 unchanged
(def v3 (assoc v1 2 99)) ;; => [1 2 99 4 5] — v1 unchanged
(nth v1 2) ;; => 3
(count v1) ;; => 5
(peek v1) ;; => 5 (last element, O(1))
(pop v1) ;; => [1 2 3 4]
;; into — pour a collection into a target collection type:
(into [] #{:a :b :c}) ;; => [:a :b :c] (order not guaranteed from set)
(into #{} [1 1 2 2 3]) ;; => #{1 2 3} (deduplication)
(into {} [[:a 1] [:b 2]]) ;; => {:a 1 :b 2}
;; Persistent maps — O(log32 n) lookup, assoc, dissoc:
(def m1 {:user/name "Alice" :user/email "alice@example.com" :user/active? true})
(def m2 (assoc m1 :user/role :admin))
(def m3 (dissoc m1 :user/active?))
(get m1 :user/name) ;; => "Alice"
(:user/name m1) ;; => "Alice" — keywords are functions of maps
(get m1 :missing "default") ;; => "default"
(update m1 :user/name str "/edited") ;; => {:user/name "Alice/edited" ...}
(merge m1 {:user/role :member :user/active? false}) ;; => merged map, later wins
;; Nested map manipulation:
(def user {:profile {:name "Bob" :age 30} :settings {:theme :dark}})
(get-in user [:profile :name]) ;; => "Bob"
(assoc-in user [:profile :age] 31) ;; => {:profile {:name "Bob" :age 31} ...}
(update-in user [:settings :theme] name) ;; => {:settings {:theme "dark"} ...}
;; Persistent sets:
(def s1 #{:a :b :c})
(conj s1 :d) ;; => #{:a :b :c :d}
(disj s1 :b) ;; => #{:a :c}
(contains? s1 :a) ;; => true
(clojure.set/union s1 #{:c :d :e}) ;; => #{:a :b :c :d :e}
(clojure.set/intersection s1 #{:b :c :d}) ;; => #{:b :c}
(clojure.set/difference s1 #{:b :c}) ;; => #{:a}
;; Lists — linked list, O(1) prepend (conj at front), O(n) indexed access:
(def l1 '(1 2 3))
(conj l1 0) ;; => (0 1 2 3) — conj prepends to lists
(first l1) ;; => 1
(rest l1) ;; => (2 3)
(peek l1) ;; => 1 (first element for lists)
;; reduce — fundamental accumulation over collections:
(reduce + 0 [1 2 3 4 5]) ;; => 15
(reduce (fn [acc x] (assoc acc x (* x x))) {} [1 2 3 4])
;; => {1 1, 2 4, 3 9, 4 16}
;; Thread-last and thread-first macros for composing transformations:
(->> [1 2 3 4 5 6 7 8 9 10]
(filter odd?)
(map #(* % %))
(reduce +))
;; => 165 (1 + 9 + 25 + 49 + 81)
(-> {:count 0}
(assoc :name "counter")
(update :count inc)
(update :count + 4))
;; => {:count 5, :name "counter"}
Lazy sequences
Clojure’s sequence abstraction is lazy by default: map, filter, take, drop, and related functions return lazy sequences that only realize elements when consumed. This allows infinite sequences and avoids materializing large intermediate collections. A Clojure architect on retainer identifies where lazy sequence realization pitfalls — holding the head of a lazy seq in a closure, forcing realization inside a dosync transaction, or chunked seq behavior interacting with side-effectful functions — cause correctness and memory problems.
;; Lazy sequences — not realized until consumed:
(def lazy-evens (filter even? (range))) ;; infinite sequence of even numbers
(take 5 lazy-evens) ;; => (0 2 4 6 8)
;; map, filter, take, drop — all return lazy sequences:
(map #(* % %) (range 1 6)) ;; => (1 4 9 16 25)
(filter odd? (range 10)) ;; => (1 3 5 7 9)
(take 3 (drop 5 (range 10))) ;; => (5 6 7)
;; partition and partition-all — chunk a sequence into sub-sequences:
(partition 3 [1 2 3 4 5 6 7 8 9]) ;; => ((1 2 3) (4 5 6) (7 8 9))
(partition 3 2 [1 2 3 4 5 6 7]) ;; => ((1 2 3) (3 4 5) (5 6 7)) — step 2, overlap
(partition-all 3 [1 2 3 4 5]) ;; => ((1 2 3) (4 5)) — includes partial final chunk
(partition 3 3 [] [1 2 3 4 5]) ;; => ((1 2 3) (4 5)) — padding with []
;; flatten — recursively flatten nested sequences:
(flatten [[1 [2 3]] [4 [5 [6]]]]) ;; => (1 2 3 4 5 6)
;; mapcat — map then concatenate (flatMap):
(mapcat (fn [n] (range n)) [3 2 1]) ;; => (0 1 2 0 1 0)
(mapcat #(list % (* % %)) [1 2 3]) ;; => (1 1 2 4 3 9)
;; lazy-seq — implement your own lazy sequence:
(defn integers-from [n]
(lazy-seq (cons n (integers-from (inc n)))))
(take 5 (integers-from 10)) ;; => (10 11 12 13 14)
;; Realization pitfalls:
;; PITFALL 1 — holding the head of a lazy seq prevents GC of realized elements:
;; BAD: storing the head reference while iterating the tail
(let [all-data (range 10000000) ;; 10M element lazy seq
head all-data] ;; holding head prevents GC — OutOfMemoryError
(last all-data))
;; GOOD: don't hold the head reference
(last (range 10000000)) ;; GC can collect realized elements as iteration proceeds
;; PITFALL 2 — side effects in lazy sequences fire at realization time, not definition time:
;; BAD: expecting println to fire immediately
(def side-effectful (map (fn [x] (println "processing" x) (* x x)) (range 5)))
;; Nothing prints yet — the sequence is lazy
(take 2 side-effectful) ;; Now "processing 0" and "processing 1" print
;; BUT chunked seqs may realize 32 elements at a time:
;; "processing 0" through "processing 31" may all print for (take 1 ...)
;; PITFALL 3 — doall to force full realization when you need all side effects:
(doall (map (fn [x] (println x) x) (range 5))) ;; forces all elements, returns realized seq
(dorun (map println (range 5))) ;; forces realization, discards result (nil)
;; PITFALL 4 — forcing realization inside dosync:
;; BAD: lazy seq realized inside a transaction may cause the transaction to retry
;; and fire side effects multiple times — always realize before entering dosync
(let [data (doall (fetch-from-db))] ;; realize outside transaction
(dosync (ref-set state data)))
Transducers
Transducers are composable transformation functions that are decoupled from the input and output collection type. A transducer stack created with comp applies all transformations in a single pass without allocating intermediate collections, making them significantly faster than chained lazy sequence operations for large-volume data processing. A Clojure architect on retainer selects transducers over lazy sequences when throughput is the primary concern and when the same transformation stack needs to apply to different source types (channels, sequences, streams).
;; Transducers — composable, collection-agnostic transformations:
;; A transducer is created by calling map/filter/take without a collection:
(def xf-square (map #(* % %))) ;; transducer: squares each element
(def xf-odd (filter odd?)) ;; transducer: keeps odd elements
(def xf-ten (take 10)) ;; transducer: takes first 10
;; Compose transducers with comp (applied left-to-right):
(def xf (comp (filter odd?)
(map #(* % %))
(take 5)))
;; transduce — apply a transducer stack over a collection with a reducing function:
(transduce xf + (range 100)) ;; => 165 (1+9+25+49+81 — squares of first 5 odd numbers)
(transduce xf conj [] (range 100)) ;; => [1 9 25 49 81]
;; into — most common: apply transducer and collect into a target:
(into [] xf (range 100)) ;; => [1 9 25 49 81]
(into #{} (map :id) users) ;; => #{1 2 3 ...} — extract ids into a set
;; eduction — lazy, re-runnable transducer application (no allocation until consumed):
(def processed (eduction xf (range 100)))
(first processed) ;; => 1
(take 3 processed) ;; => (1 9 25) — re-runs the transducer from the start each time
;; Transducers over core.async channels:
(require '[clojure.core.async :as async])
(def ch (async/chan 1000 xf)) ;; channel applies transducer to each item as it passes through
(async/>!! ch 2) ;; 2 is odd? no — filtered out before entering the channel
(async/>!! ch 3) ;; 3 is odd? yes — squared to 9 — enters channel
(async/ 9
;; Stateful transducers — maintain state across elements:
;; dedupe — removes consecutive duplicates:
(into [] (dedupe) [1 1 2 2 3 3 3 2 1]) ;; => [1 2 3 2 1]
;; partition-all as transducer — chunks elements:
(into [] (partition-all 3) (range 10)) ;; => [[0 1 2] [3 4 5] [6 7 8] [9]]
;; Custom stateful transducer — sliding window sum:
(defn windowed-sum [n]
(fn [rf]
(let [window (volatile! (clojure.lang.PersistentQueue/EMPTY))]
(fn
([] (rf)) ;; init arity
([acc] (rf acc)) ;; completion arity
([acc x] ;; step arity
(vswap! window (fn [q]
(let [q (conj q x)]
(if (> (count q) n) (pop q) q))))
(rf acc (reduce + @window)))))))
(into [] (windowed-sum 3) [1 2 3 4 5])
;; => [1 3 6 9 12] — running sums of windows of 3
;; When to prefer transducers over lazy sequences:
;; - Processing large collections where intermediate allocation is measurable
;; - Same transformation stack applies to different source types (seq, channel, stream)
;; - Throughput benchmarks (criterium) show lazy seq overhead is significant
;; - Chaining more than 3 transformation steps (each lazy seq step is a separate object)
;; When lazy sequences are fine:
;; - Small collections where allocation is negligible
;; - Code is simpler and readability is more important than throughput
;; - Streaming is needed (processing before full realization)
clojure.spec.alpha
clojure.spec.alpha provides declarative specifications for data shapes and function contracts. Specs are data-driven, composable, and support generative testing: given a spec, test.check generates conforming values automatically and tests them against functions whose :args and :ret specs have been declared with s/fdef. A Clojure architect on retainer uses spec to define API payload validation, instrument function contracts in development and testing environments, and run generative tests that discover edge cases no human test author would think to write.
(require '[clojure.spec.alpha :as s]
'[clojure.spec.gen.alpha :as gen]
'[clojure.spec.test.alpha :as stest])
;; Primitive specs:
(s/def :user/id pos-int?)
(s/def :user/name (s/and string? #(< 1 (count %) 200)))
(s/def :user/email (s/and string? #(re-matches #".+@.+\..+" %)))
(s/def :user/role #{:admin :member :viewer})
(s/def :user/active? boolean?)
;; Map spec — s/keys:
(s/def :user/user
(s/keys :req [:user/id :user/name :user/email :user/role]
:opt [:user/active?]))
;; Validate:
(s/valid? :user/user {:user/id 1 :user/name "Alice" :user/email "alice@example.com" :user/role :admin})
;; => true
(s/valid? :user/user {:user/id -1 :user/name "Alice" :user/email "bad" :user/role :admin})
;; => false
;; s/explain — human-readable explanation of why validation failed:
(s/explain :user/user {:user/id -1 :user/email "bad" :user/role :unknown})
;; val: -1 fails spec: :user/id predicate: pos-int?
;; val: "bad" fails spec: :user/email predicate: #(re-matches #".+@.+\..+" %)
;; val: :unknown fails spec: :user/role predicate: #{:admin :member :viewer}
;; s/conform — validate and return the conformed value (or ::s/invalid):
(s/conform :user/role :admin) ;; => :admin
(s/conform :user/role :unknown) ;; => :s/invalid
;; Sequence specs with s/cat — named positional elements:
(s/def :payment/create-args
(s/cat :amount pos-int?
:currency #{"USD" "EUR" "GBP"}
:metadata (s/? map?))) ;; ? = optional
(s/valid? :payment/create-args [1000 "USD"]) ;; => true
(s/valid? :payment/create-args [1000 "USD" {:ref "X"}]) ;; => true
(s/conform :payment/create-args [1000 "USD"])
;; => {:amount 1000, :currency "USD"}
;; s/and — intersection of predicates:
(s/def :payment/amount
(s/and pos-int?
#(< % 1000000) ;; max $10,000 in cents
#(zero? (mod % 1)))) ;; must be integer cents
;; s/or — union (tagged):
(s/def :api/id
(s/or :string-id (s/and string? #(re-matches #"[a-z0-9-]+" %))
:int-id pos-int?))
(s/conform :api/id "abc-123") ;; => [:string-id "abc-123"]
(s/conform :api/id 42) ;; => [:int-id 42]
;; Function specs with s/fdef:
(defn create-payment [amount currency metadata]
{:payment/id (random-uuid)
:payment/amount amount
:payment/currency currency
:payment/metadata metadata})
(s/fdef create-payment
:args (s/cat :amount :payment/amount
:currency #{"USD" "EUR" "GBP"}
:metadata (s/nilable map?))
:ret (s/keys :req [:payment/id :payment/amount :payment/currency]))
;; Instrument in REPL/tests — wraps function to validate :args on call:
(stest/instrument `create-payment)
;; Generative testing — generate conforming inputs and run the function:
(s/exercise :payment/amount 5)
;; => ([1 1] [2 2] [3 3] [10 10] [47 47]) — [generated conformed]
(stest/check `create-payment {:clojure.spec.test.check/opts {:num-tests 1000}})
;; Runs create-payment with 1000 generated valid argument combinations
;; Reports any case where :ret spec is violated
Multimethods and protocols
Clojure provides two extensible polymorphism mechanisms. defmulti/defmethod dispatch on an arbitrary function of the arguments — enabling polymorphism based on data values, types, combinations, or any computable property. Protocols define typed dispatch with Java-interop performance and are best when dispatching on the type of the first argument. A Clojure architect on retainer selects between them based on the dispatch requirements: multimethods for value-based dispatch in domain logic, protocols for type-based dispatch in library abstractions.
;; defmulti — dispatch on a function of arguments:
(defmulti process-event :event/type)
(defmethod process-event :payment/created [event]
(let [{:keys [payment/id payment/amount]} event]
(println "Processing payment" id "for" amount "cents")))
(defmethod process-event :payment/refunded [event]
(let [{:keys [payment/id payment/amount payment/reason]} event]
(println "Processing refund for payment" id "reason:" reason)))
(defmethod process-event :default [event]
(println "Unknown event type:" (:event/type event)))
;; Dispatch on multiple keys using a derived dispatch value:
(defmulti notify (fn [event user] [(:event/type event) (:user/role user)]))
(defmethod notify [:payment/created :admin] [event user]
(send-admin-alert event user))
(defmethod notify [:payment/created :member] [event user]
(send-email event user))
;; prefer and derive — type hierarchy for multimethod dispatch:
(derive ::savings-account ::bank-account)
(derive ::checking-account ::bank-account)
(defmulti calculate-fee :account/type)
(defmethod calculate-fee ::bank-account [account] (* (:balance account) 0.001))
(defmethod calculate-fee ::savings-account [account] 0) ;; more specific, wins
(calculate-fee {:account/type ::savings-account :balance 1000}) ;; => 0
;; defprotocol — type-based polymorphism, JVM interface-like performance:
(defprotocol Serializable
(serialize [this format] "Serialize to the given format")
(deserialize [this data] "Deserialize from data"))
(defprotocol Auditable
(audit-fields [this] "Return map of fields to log for audit"))
;; defrecord — value type implementing protocols:
(defrecord Payment [id amount currency metadata]
Serializable
(serialize [this format]
(case format
:json (cheshire.core/generate-string (into {} this))
:edn (pr-str (into {} this))
:transit (transit-write (into {} this))))
(deserialize [this data]
(map->Payment (clojure.edn/read-string data)))
Auditable
(audit-fields [this]
{:payment/id id
:payment/amount amount
:payment/currency currency}))
;; reify — anonymous implementation (for adapters, test doubles):
(defn make-mock-serializable [data]
(reify Serializable
(serialize [_ _] (pr-str data))
(deserialize [_ d] (clojure.edn/read-string d))))
;; extend-protocol — extend an existing type to implement a protocol:
(extend-protocol Auditable
clojure.lang.PersistentHashMap
(audit-fields [this]
(select-keys this [:id :user-id :timestamp]))
nil
(audit-fields [_] {}))
;; Java interop — calling Java methods:
(.toUpperCase "hello") ;; => "HELLO"
(.substring "hello world" 6) ;; => "world"
(Math/sqrt 16.0) ;; => 4.0 static method
(System/getProperty "java.version") ;; => "21.0.3"
(java.time.Instant/now) ;; => #inst"2026-08-28T..."
(.toString (java.util.UUID/randomUUID)) ;; => "550e8400-..."
Concurrency
Clojure’s concurrency model distinguishes four kinds of mutable state by their coordination requirements: atom for independent synchronous updates, ref for coordinated synchronous updates (STM), agent for independent asynchronous updates, and var for thread-local dynamic binding. Choosing the wrong primitive is a common source of subtle bugs in Clojure systems — particularly when developers reach for atom for state that actually requires coordination across multiple references.
Atoms, refs, agents, and vars
;; atom — independent synchronous state change, CAS-based:
(def counter (atom 0))
(swap! counter inc) ;; => 1 — atomically applies inc
(swap! counter + 10) ;; => 11 — atomically applies + with additional args
(reset! counter 0) ;; => 0 — unconditional reset (avoid if possible)
(compare-and-set! counter 0 42) ;; => true — CAS: set to 42 only if current value is 0
;; swap! can be called concurrently from many threads — Clojure retries on contention:
;; f must be pure! It may be called multiple times if CAS fails.
(def cache (atom {}))
(defn cache-result! [key val]
(swap! cache assoc key val)) ;; safe — assoc is pure
;; ref and STM — coordinated synchronous multi-ref updates:
(def account-a (ref 1000))
(def account-b (ref 500))
(defn transfer! [from to amount]
(dosync
(when (< @from amount)
(throw (ex-info "Insufficient funds" {:from @from :amount amount})))
(alter from - amount) ;; alter: apply a function to the ref's value
(alter to + amount))) ;; both updates happen atomically or not at all
(transfer! account-a account-b 200)
;; account-a now holds 800, account-b holds 700, atomically
;; alter vs. commute — commute allows out-of-order execution for commutative ops:
(dosync
(commute page-views + 1)) ;; page-view increment is commutative — commute is faster
;; than alter because it doesn't require serialization
;; ensure — read a ref and prevent other transactions from modifying it:
(dosync
(ensure balance) ;; read balance, fail if another tx modifies it before commit
(when (pos? @balance)
(alter account withdraw-amount)))
;; agent — independent asynchronous state change, executed on a thread pool:
(def logger-agent (agent []))
(send logger-agent conj {:event :payment-processed :ts (System/currentTimeMillis)})
;; send is non-blocking — the update is queued and applied asynchronously
;; send uses the agent's dedicated thread pool (fork-join pool)
;; send-off — for I/O-blocking operations (uses a separate thread pool):
(def file-agent (agent nil))
(send-off file-agent (fn [_] (spit "output.log" "data\n" :append true)))
;; await — block until all queued actions have completed:
(await logger-agent)
;; var — thread-local dynamic binding:
(def ^:dynamic *request-id* nil)
(defn with-request-id [id f]
(binding [*request-id* id]
(f)))
;; Inside with-request-id, *request-id* is thread-locally bound:
(with-request-id "req-123"
(fn []
(println "Processing request:" *request-id*)))
;; => "Processing request: req-123"
;; future, promise, pmap:
(def f (future (Thread/sleep 1000) (+ 1 2)))
@f ;; => 3 — blocks until the future completes
(def p (promise))
(future (Thread/sleep 500) (deliver p :done))
@p ;; => :done — blocks until delivery
(pmap #(* % %) (range 100)) ;; parallel map — uses future per element, good for CPU-heavy work
core.async channel topology
core.async provides CSP-style concurrency with channels and lightweight processes. The channel topology design — which channels are buffered, what capacity, which operations are blocking, which go-blocks handle I/O, which pipeline-async calls handle CPU work — is one of the highest-leverage decisions in a Clojure system. Getting it wrong, as in the fintech case at the opening of this post, causes silent message loss under load.
(require '[clojure.core.async :as async
:refer [chan go go-loop >! !! ! and ! buffered {:event :payment-processed :id 42}))
;; >!! and !! buffered {:event :payment-processed :id 42}) ;; blocks caller thread if full
(! result-ch result)
(close! result-ch))))
input-ch)
;; alt! — choose between multiple channel operations (non-deterministic if multiple ready):
(go
(alt!
input-ch ([v] (println "Got from input:" v))
control-ch ([v] (when (= v :shutdown) (close! input-ch)))))
;; alts! — same but returns [value channel] pair:
(go-loop []
(let [[v ch] (alts! [input-ch control-ch (async/timeout 5000)])]
(cond
(= ch control-ch) (println "Shutdown signal")
(nil? v) (println "Channel closed")
:else (do (process-event v) (recur)))))
;; Fan-out — one producer, multiple consumers:
(defn fan-out [in out-channels]
(go-loop []
(when-let [v (! ch v))
(recur))))
;; Fan-in — multiple producers, one consumer:
(defn fan-in [in-channels out]
(doseq [ch in-channels]
(go-loop []
(when-let [v (! out v)
(recur)))))
;; The fintech pipeline redesign — bounded channels with dead-letter handling:
(defn build-payment-pipeline []
(let [intake-ch (chan 1000) ;; bounded: backpressure via >!! from producers
validate-ch (chan 500)
settle-ch (chan 500)
dead-ch (chan 200) ;; dead-letter: validation failures
validate-xf (comp
(map normalize-payment-fields)
(filter #(s/valid? :payment/payment %)))]
;; CPU-bound validation step — pipeline with transducer:
(pipeline 4 validate-ch validate-xf intake-ch)
;; I/O-bound settlement step — pipeline-async for non-blocking DB writes:
(pipeline-async
8
settle-ch
(fn [payment result-ch]
(go (let [result (async/! result-ch result)
(close! result-ch))))
validate-ch)
;; Dead-letter consumer — persist failed events:
(go-loop []
(when-let [failed (!! for backpressure (will block if intake-ch full):
(defn kafka-consumer-loop [pipeline]
(doseq [record (kafka-records)]
(>!! (:intake pipeline) (parse-kafka-record record))))
Macros
Clojure macros operate at compile time, transforming code forms before evaluation. Because Clojure is homoiconic — code is data — macros receive their arguments as unevaluated data structures and return new data structures to be evaluated. A Clojure architect on retainer writes macros to eliminate repetitive patterns in routing definitions, configuration, business rules, and observability instrumentation — and uses macroexpand-1 to verify expansion during development.
Macro fundamentals and hygiene
;; defmacro — defines a macro:
;; Arguments are unevaluated forms (data), return value is the expanded form.
;; Simple example — when-valid:
(defmacro when-valid [spec value & body]
`(when (s/valid? ~spec ~value)
~@body))
;; Expansion:
(macroexpand-1 '(when-valid :user/user user (save! user)))
;; => (clojure.core/when (clojure.spec.alpha/valid? :user/user user) (save! user))
;; Syntax-quote ` — quotes the form but resolves namespace symbols:
;; Unquote ~ — evaluates the expression inside a syntax-quoted form:
;; Unquote-splicing ~@ — splices a sequence into the surrounding form:
(defmacro unless [condition & body]
`(when (not ~condition)
~@body))
(unless false (println "runs")) ;; => "runs"
(unless true (println "skipped")) ;; => nothing
;; Gensym — generates a unique symbol to prevent variable capture (hygiene):
;; Inside syntax-quote, appending # automatically generates a unique symbol:
(defmacro with-timing [& body]
`(let [start# (System/nanoTime)
result# (do ~@body)
elapsed# (/ (- (System/nanoTime) start#) 1e6)]
(println "Elapsed:" elapsed# "ms")
result#))
;; Without gensym, a macro that introduces a local binding can capture variables:
;; BAD — captures 'result' if the user also has a local named 'result':
(defmacro bad-with-timing [& body]
`(let [result (do ~@body)] ;; 'result' is not unique — can shadow user's variable
result))
;; macroexpand-1 — expands one level of macros (use in REPL to debug):
(macroexpand-1 '(with-timing (+ 1 2)))
;; => (clojure.core/let [start__1234__auto__ (java.lang.System/nanoTime)
;; result__1235__auto__ (do (+ 1 2))
;; elapsed__1236__auto__ ...]
;; (clojure.core/println "Elapsed:" elapsed__1236__auto__ "ms")
;; result__1235__auto__)
;; macroexpand — recursively expands until no more macros remain:
(macroexpand '(-> x (assoc :a 1) (update :b inc)))
;; => (update (assoc x :a 1) :b inc) — threading fully expanded
;; &form and &env — available inside defmacro:
;; &form — the original macro form (with metadata including line number)
;; &env — local bindings in scope at the call site (a map of symbol to local binding)
(defmacro assert-arg [form pred msg]
`(when-not (~pred ~form)
(throw (ex-info ~msg {:form '~form
:caller-line ~(:line (meta &form))}))))
;; Common threading macros — not macros to write, but to understand:
;; cond-> — thread only when a condition is true:
(-> {:query "SELECT * FROM payments"}
(cond-> start-date (assoc :start start-date))
(cond-> end-date (assoc :end end-date))
(cond-> status (assoc :status status)))
;; as-> — bind the threaded value to a name for non-first-argument threading:
(as-> 0 x
(inc x) ;; x = 1
(* x 3) ;; x = 3
(range x)) ;; x = (0 1 2)
;; some-> — stops threading and returns nil if any step returns nil:
(some-> user :user/profile :profile/address :address/city str/upper-case)
;; Returns nil immediately if any key is missing, instead of throwing NPE
Writing a real macro: defn-traced
A practical retainer use case for macros is observability instrumentation: wrapping function definitions to log entry, exit, and timing without changing call sites. The defn-traced macro below generates a traced function definition that logs the function name, arguments, return value, and elapsed time on every invocation.
;; defn-traced — wraps a function definition with entry/exit logging and timing.
;; Usage: (defn-traced my-fn [x y] (+ x y))
;; Effect: identical to defn but logs on every call.
(defmacro defn-traced
"Like defn, but logs function entry, exit, and elapsed time on every call.
Supports single-arity and multi-arity functions.
Disable tracing at runtime by setting the traced-enabled? atom to false."
[fn-name & fn-tail]
(let [doc-string (when (string? (first fn-tail)) (first fn-tail))
fn-tail (if doc-string (rest fn-tail) fn-tail)
;; Normalize to multi-arity form: wrap single arity in a list
arities (if (vector? (first fn-tail))
(list fn-tail) ;; single arity: ([x y] body)
fn-tail) ;; multi-arity: (([x] body1) ([x y] body2))
;; Generate traced arity definitions
traced-arities
(map (fn [[params & body]]
(let [result-sym# (gensym "result")
start-sym# (gensym "start")
args-sym# (gensym "args")]
`(~params
(let [~args-sym# (vector ~@params)
~start-sym# (System/nanoTime)]
(println (str "[TRACE] ENTER " '~fn-name " args=" ~args-sym#))
(let [~result-sym# (do ~@body)
elapsed# (/ (- (System/nanoTime) ~start-sym#) 1e6)]
(println (str "[TRACE] EXIT " '~fn-name
" => " ~result-sym#
" (" (format "%.2f" elapsed#) "ms)"))
~result-sym#)))))
arities)]
`(defn ~fn-name
~@(when doc-string [doc-string])
~@traced-arities)))
;; Usage:
(defn-traced validate-payment
"Validates a payment map against the payment spec."
[payment]
(if (s/valid? :payment/payment payment)
{:valid? true :payment payment}
{:valid? false :errors (s/explain-data :payment/payment payment)}))
;; Calling it produces:
;; [TRACE] ENTER validate-payment args=[{:payment/amount 1000, :payment/currency "USD"}]
;; [TRACE] EXIT validate-payment => {:valid? true, ...} (0.42ms)
;; Multi-arity traced function:
(defn-traced calculate-fee
([account]
(calculate-fee account :standard))
([account tier]
(case tier
:standard (* (:balance account) 0.001)
:premium (* (:balance account) 0.0005)
:free 0)))
;; Verify macro expansion:
(macroexpand-1
'(defn-traced simple-add [x y] (+ x y)))
;; Expands to a defn with the logging/timing wrapper injected around the body.
;; Related built-in threading macros that use similar patterns:
;; when-let — bind and execute body only when binding is truthy:
(when-let [user (find-user user-id)]
(send-welcome-email user))
;; if-let — two branches based on binding:
(if-let [payment (find-pending-payment id)]
(process-payment payment)
(create-payment id))
;; some->> — thread with nil short-circuit (thread-last):
(some->> events
(filter active?)
(map transform)
(take 100)
(run! persist!))
Web stack
Clojure’s web ecosystem is built around the Ring specification — request and response as plain Clojure maps — with middleware as pure functions that wrap handlers. Three frameworks dominate production deployments: Compojure with Ring for simple route trees, Reitit for data-driven routing with schema coercion, and Pedestal for interceptor-based systems requiring fine-grained request/response pipeline control. On the frontend, ClojureScript with shadow-cljs and Re-frame provides a functional reactive architecture with Clojure’s immutability guarantees applied to UI state.
Ring and Compojure
(require '[ring.adapter.jetty :as jetty]
'[ring.middleware.params :refer [wrap-params]]
'[ring.middleware.json :refer [wrap-json-body wrap-json-response]]
'[ring.middleware.cors :refer [wrap-cors]]
'[compojure.core :refer [defroutes GET POST PUT DELETE context]]
'[compojure.route :as route])
;; Ring request and response maps:
;; Request: {:request-method :get :uri "/payments" :headers {...} :query-params {...} :body ...}
;; Response: {:status 200 :headers {"Content-Type" "application/json"} :body "..."}
;; Handler — pure function from request to response:
(defn list-payments-handler [request]
(let [user-id (get-in request [:session :user-id])
status (get-in request [:query-params "status"])
payments (find-payments {:user-id user-id :status status})]
{:status 200
:headers {"Content-Type" "application/json"}
:body (cheshire.core/generate-string payments)}))
;; Middleware — wraps handlers with cross-cutting behavior:
;; A middleware is a function that takes a handler and returns a new handler.
(defn wrap-authentication [handler]
(fn [request]
(if-let [user (authenticate-request request)]
(handler (assoc request :current-user user))
{:status 401
:headers {"Content-Type" "application/json"}
:body (cheshire.core/generate-string {:error "Unauthorized"})})))
(defn wrap-request-logging [handler]
(fn [request]
(let [start (System/currentTimeMillis)
response (handler request)
elapsed (- (System/currentTimeMillis) start)]
(println (format "[%s] %s %s %dms"
(:request-method request)
(:uri request)
(:status response)
elapsed))
response)))
;; Compojure routes — DSL for URL routing:
(defroutes payment-routes
(GET "/payments" request (list-payments-handler request))
(POST "/payments" request (create-payment-handler request))
(GET "/payments/:id" [id] (get-payment-handler id))
(PUT "/payments/:id" [id :as request]
(update-payment-handler id request))
(DELETE "/payments/:id" [id] (delete-payment-handler id)))
(defroutes app-routes
(context "/api/v1" []
(context "/payments" [] payment-routes)
(context "/users" [] user-routes))
(route/not-found {:status 404 :body "Not found"}))
;; Middleware composition — wrap-* functions applied inside-out:
(def app
(-> app-routes
wrap-authentication
wrap-request-logging
(wrap-cors :access-control-allow-origin [#".*"]
:access-control-allow-methods [:get :post :put :delete])
(wrap-json-body {:keywords? true})
wrap-json-response
wrap-params))
;; Start the server:
(jetty/run-jetty app {:port 3000 :join? false})
Reitit data-driven routing
(require '[reitit.ring :as ring]
'[reitit.coercion.spec :as spec-coercion]
'[reitit.ring.coercion :as rrc]
'[reitit.ring.middleware.muuntaja :as muuntaja]
'[muuntaja.core :as m])
;; Reitit — data-driven routing with per-route middleware and coercion:
(def router
(ring/router
[["/api"
{:middleware [muuntaja/format-middleware
rrc/coerce-exceptions-middleware
rrc/coerce-request-middleware
rrc/coerce-response-middleware]}
["/payments"
{:get {:parameters {:query {:status (s/? keyword?)
:user-id pos-int?}}
:responses {200 {:body (s/coll-of :payment/payment)}}
:handler (fn [{{{:keys [status user-id]} :query} :parameters}]
{:status 200
:body (find-payments {:status status :user-id user-id})})}
:post {:parameters {:body :payment/create-request}
:responses {201 {:body :payment/payment}}
:handler (fn [{{{:keys [amount currency]} :body} :parameters}]
(let [payment (create-payment! amount currency)]
{:status 201 :body payment}))}}]
["/payments/:id"
{:parameters {:path {:id pos-int?}} ;; path param coercion applied to all methods
:get {:responses {200 {:body :payment/payment}
404 {:body {:error string?}}}
:handler (fn [{{{:keys [id]} :path} :parameters}]
(if-let [p (find-payment id)]
{:status 200 :body p}
{:status 404 :body {:error "Not found"}}))}
:put {:parameters {:body :payment/update-request}
:handler (fn [{{{:keys [id]} :path
body :body} :parameters}]
{:status 200 :body (update-payment! id body)})}}]]]
{:data {:coercion spec-coercion/coercion
:muuntaja m/instance
:middleware []}}))
(def app
(ring/ring-handler
router
(ring/create-default-handler)
{:middleware [wrap-authentication wrap-request-logging]}))
Pedestal interceptors
(require '[io.pedestal.http :as http]
'[io.pedestal.http.route :as route]
'[io.pedestal.interceptor :refer [interceptor]]
'[io.pedestal.interceptor.chain :as chain])
;; Pedestal interceptors — have :enter, :leave, :error handlers:
;; Unlike Ring middleware (which wraps), interceptors form a queue and stack:
;; - :enter handlers run in order (left to right) on the way in
;; - :leave handlers run in reverse order on the way out
;; - :error handlers run when an exception propagates up the chain
(def authentication-interceptor
(interceptor
{:name ::authentication
:enter (fn [context]
(let [request (:request context)
user (authenticate-request request)]
(if user
(assoc-in context [:request :current-user] user)
(chain/terminate
(assoc context :response
{:status 401
:headers {"Content-Type" "application/json"}
:body (cheshire.core/generate-string {:error "Unauthorized"})})))))
:leave (fn [context] context) ;; no-op on the way out
:error (fn [context ex] ;; propagate errors upward
(assoc context :io.pedestal.interceptor.chain/error ex))}))
(def logging-interceptor
(interceptor
{:name ::request-logging
:enter (fn [context]
(assoc context :start-time (System/nanoTime)))
:leave (fn [context]
(let [elapsed (/ (- (System/nanoTime) (:start-time context)) 1e6)
req (:request context)
res (:response context)]
(println (format "[%s] %s %d %.2fms"
(name (:request-method req))
(:uri req)
(:status res)
elapsed))
context))}))
;; Route definition:
(def routes
(route/expand-routes
#{["/api/payments" :get [authentication-interceptor list-payments-handler] :route-name ::list-payments]
["/api/payments" :post [authentication-interceptor create-payment-handler] :route-name ::create-payment]
["/api/payments/:id" :get [authentication-interceptor get-payment-handler] :route-name ::get-payment]}))
;; Service definition and start:
(def service
{:env :prod
::http/routes routes
::http/type :jetty
::http/port 3000
::http/resource-path "/public"
::http/interceptors [logging-interceptor]}) ;; global interceptors
(defonce server (atom nil))
(defn start! []
(reset! server (-> service http/create-server http/start)))
(defn stop! []
(http/stop @server))
ClojureScript, shadow-cljs, and Re-frame
;; shadow-cljs.edn — project configuration:
{:source-paths ["src"]
:dependencies [[reagent "1.2.0"]
[re-frame "1.3.0"]
[cljs-ajax "0.8.4"]]
:builds
{:app {:target :browser
:output-dir "public/js"
:asset-path "/js"
:modules {:main {:init-fn app.core/init}}
:devtools {:after-load app.core/after-load ;; hot reload callback
:preloads [devtools.preload]}}}}
;; Re-frame application — unidirectional data flow:
;; Event → Event Handler → app-db (single atom) → Subscription → View
(require '[re-frame.core :as rf]
'[reagent.core :as r])
;; App state — single atom holding the entire application state:
;; Initialized on startup:
(rf/reg-event-db
::initialize-db
(fn [_ _]
{:payments []
:loading? false
:current-user nil
:filters {:status nil :page 1}}))
;; Event handlers — pure functions from [db event] to new-db:
(rf/reg-event-db
::set-payments
(fn [db [_ payments]]
(assoc db :payments payments :loading? false)))
(rf/reg-event-db
::set-filter
(fn [db [_ key value]]
(assoc-in db [:filters key] value)))
;; Effectful event handler — reg-event-fx allows side effects:
(rf/reg-event-fx
::load-payments
(fn [{:keys [db]} [_ filters]]
{:db (assoc db :loading? true)
:fx [[:dispatch-later {:ms 0 :dispatch [::fetch-payments filters]}]]
:http-xhrio {:method :get
:uri "/api/payments"
:params filters
:response-format (ajax.core/json-response-format {:keywords? true})
:on-success [::set-payments]
:on-failure [::payment-load-failed]}}))
;; Custom effect handler:
(rf/reg-fx
:local-storage
(fn [{:keys [key value]}]
(.setItem js/localStorage key (js/JSON.stringify (clj->js value)))))
;; Coeffect — inject external state into event handlers (makes them testable):
(rf/reg-cofx
:local-storage
(fn [coeffects key]
(assoc coeffects :local-storage
(-> js/localStorage (.getItem key) js/JSON.parse (js->clj :keywordize-keys true)))))
;; Subscriptions — derive views from app-db:
(rf/reg-sub
::payments
(fn [db _] (:payments db)))
(rf/reg-sub
::loading?
(fn [db _] (:loading? db)))
;; Computed subscription — filters payments by status:
(rf/reg-sub
::filtered-payments
(fn [_ _] (rf/subscribe [::payments])) ;; signal subscription
(fn [payments [_ status]]
(if status
(filter #(= (:status %) status) payments)
payments)))
;; View components — Reagent (React wrapper):
(defn payment-row [payment]
[:tr {:key (:id payment)}
[:td (:id payment)]
[:td (/ (:amount payment) 100.0)]
[:td (:currency payment)]
[:td (:status payment)]])
(defn payments-table []
(let [payments @(rf/subscribe [::filtered-payments nil])
loading? @(rf/subscribe [::loading?])]
(if loading?
[:div.loading "Loading..."]
[:table
[:thead [:tr [:th "ID"] [:th "Amount"] [:th "Currency"] [:th "Status"]]]
[:tbody (map payment-row payments)]])))
(defn app []
[:div.app
[:h1 "Payments"]
[payments-table]])
;; Initialize and mount:
(defn init []
(rf/dispatch-sync [::initialize-db])
(rf/dispatch [::load-payments {}])
(r/render [app] (.getElementById js/document "app")))
;; Hot reload callback (called by shadow-cljs after each code change):
(defn after-load []
(r/force-update-all))
Datomic
Datomic is the database most naturally aligned with Clojure’s values: it treats the database as an immutable, append-only log of facts (datoms), makes time travel a first-class query feature, and separates reads (which scale horizontally) from writes (which go through a single transactor). A Clojure architect on retainer designs Datomic schemas, writes Datalog queries, implements transaction functions for atomic conditional writes, and uses time-travel queries for audit logging and temporal analysis — all without the destructive migration scripts that relational schema evolution requires.
Schema and datom model
(require '[datomic.api :as d])
;; Datom model: [entity-id attribute value transaction-id added?]
;; [42 :payment/amount 1000 1000001 true] -- asserted: payment 42 has amount 1000
;; [42 :payment/amount 1000 1000005 false] -- retracted: payment 42 no longer has amount 1000
;; [42 :payment/amount 1500 1000005 true] -- asserted: payment 42 now has amount 1500
;; Schema definition — attributes are also entities in Datomic:
(def payment-schema
[{:db/ident :payment/id
:db/valueType :db.type/uuid
:db/cardinality :db.cardinality/one
:db/unique :db.unique/identity ;; upsert key: find-or-create by this value
:db/doc "Unique payment identifier"}
{:db/ident :payment/amount
:db/valueType :db.type/long ;; amount in cents
:db/cardinality :db.cardinality/one
:db/doc "Payment amount in smallest currency unit (cents)"}
{:db/ident :payment/currency
:db/valueType :db.type/string
:db/cardinality :db.cardinality/one}
{:db/ident :payment/status
:db/valueType :db.type/keyword
:db/cardinality :db.cardinality/one
:db/index true} ;; indexed for query performance
{:db/ident :payment/tags
:db/valueType :db.type/string
:db/cardinality :db.cardinality/many} ;; cardinality/many: set of values
{:db/ident :payment/user
:db/valueType :db.type/ref ;; reference to another entity
:db/cardinality :db.cardinality/one}])
;; Install schema — schema is just a transaction:
@(d/transact conn {:tx-data payment-schema})
;; Schema evolution — add a new attribute without a migration:
@(d/transact conn
{:tx-data [{:db/ident :payment/processor-ref
:db/valueType :db.type/string
:db/cardinality :db.cardinality/one
:db/doc "External processor reference ID"}]})
;; Transact data — create a payment:
@(d/transact conn
{:tx-data [{:payment/id (java.util.UUID/randomUUID)
:payment/amount 5000
:payment/currency "USD"
:payment/status :payment.status/pending
:payment/user [:user/email "alice@example.com"]}
;; Transaction metadata — annotate the tx entity itself:
{:db/id "datomic.tx"
:tx/initiated-by :user/email
:tx/reason :payment-creation}]})
;; Retract a specific fact:
@(d/transact conn
{:tx-data [[:db/retract payment-eid :payment/tags "promotional"]]})
;; Retract an entire entity (all its attributes):
@(d/transact conn
{:tx-data [[:db/retractEntity payment-eid]]})
Datalog queries and pull API
;; Datalog query — find entity ids of pending payments with amount > 1000:
(d/q '[:find ?e ?amount
:where [?e :payment/status :payment.status/pending]
[?e :payment/amount ?amount]
[(> ?amount 1000)]]
(d/db conn))
;; => #{[42 5000] [67 2000] [89 3500]}
;; Query with input parameters (avoids string interpolation):
(d/q '[:find ?e ?amount ?currency
:in $ ?status ?min-amount
:where [?e :payment/status ?status]
[?e :payment/amount ?amount]
[?e :payment/currency ?currency]
[(>= ?amount ?min-amount)]]
(d/db conn)
:payment.status/pending
1000)
;; Query with a join — find payments for users with a specific role:
(d/q '[:find ?payment-id ?amount ?user-email
:in $ ?role
:where [?user :user/role ?role]
[?user :user/email ?user-email]
[?payment :payment/user ?user]
[?payment :payment/id ?payment-id]
[?payment :payment/amount ?amount]]
(d/db conn)
:admin)
;; Pull API — retrieve a tree of attributes in one call:
(d/pull (d/db conn)
[:payment/id
:payment/amount
:payment/currency
:payment/status
{:payment/user [:user/email :user/name]} ;; join — follow reference
:payment/tags]
payment-eid)
;; => {:payment/id #uuid"...", :payment/amount 5000,
;; :payment/currency "USD", :payment/status :payment.status/pending,
;; :payment/user {:user/email "alice@example.com" :user/name "Alice"},
;; :payment/tags ["promotional" "enterprise"]}
;; pull-many — pull the same pattern for multiple entities:
(d/pull-many (d/db conn)
[:payment/id :payment/amount :payment/status]
[42 67 89])
;; => [{:payment/id #uuid"..." :payment/amount 5000 :payment/status :pending} ...]
;; find + pull in a single query:
(d/q '[:find (pull ?e [:payment/id :payment/amount {:payment/user [:user/email]}])
:in $
:where [?e :payment/status :payment.status/pending]]
(d/db conn))
Time travel and history queries
;; Time travel — query the database as of a specific transaction or timestamp:
;; Find the transaction id of a specific date:
(def tx-t (d/basis-t (d/db conn))) ;; current basis-t (transaction number)
;; Query the database as of a past state:
(def past-db (d/as-of (d/db conn) #inst "2026-01-01"))
(d/q '[:find ?e :where [?e :payment/status :payment.status/pending]]
past-db)
;; Returns the set of pending payments as of January 1, 2026
;; History database — contains all asserted and retracted datoms:
(def history-db (d/history (d/db conn)))
;; Query history — all values :payment/status has ever had for a payment:
(d/q '[:find ?status ?tx ?added
:in $ ?payment-eid
:where [?payment-eid :payment/status ?status ?tx ?added]]
history-db
payment-eid)
;; => #{[:payment.status/pending 1000001 true]
;; [:payment.status/processing 1000010 true]
;; [:payment.status/pending 1000010 false] -- retraction of old value
;; [:payment.status/completed 1000020 true]
;; [:payment.status/processing 1000020 false]}
;; Full audit trail of status transitions with transaction ids
;; Enrich with transaction timestamps:
(d/q '[:find ?status ?inst ?added
:in $ ?payment-eid
:where [?payment-eid :payment/status ?status ?tx ?added]
[?tx :db/txInstant ?inst]] ;; join on the transaction entity
history-db
payment-eid)
;; => #{[:payment.status/pending #inst"2026-08-01T10:00:00" true]
;; [:payment.status/processing #inst"2026-08-01T10:05:00" true]
;; ...}
;; d/datoms — low-level index access for bulk reads:
;; :eavt — entity, attribute, value, transaction index:
(d/datoms (d/db conn) :eavt payment-eid)
;; Returns all current datoms for the entity
;; :avet — attribute, value, entity, transaction (for indexed attributes):
(d/datoms (d/db conn) :avet :payment/status :payment.status/pending)
;; Returns all entities with :payment/status = :pending (fast index scan)
;; Transaction functions — atomic conditional writes (run inside the transactor):
;; Define as database functions stored in Datomic:
@(d/transact conn
{:tx-data [{:db/ident :payment/transition-status
:db/fn (d/function
{:lang "clojure"
:params '[db payment-eid from-status to-status]
:code '(let [current (-> (d/entity db payment-eid)
:payment/status)]
(if (= current from-status)
[[:db/add payment-eid :payment/status to-status]]
(throw (ex-info "Status transition invalid"
{:current current
:from from-status
:to to-status}))))})}]})
;; Call the transaction function:
@(d/transact conn
{:tx-data [[:payment/transition-status
payment-eid
:payment.status/pending
:payment.status/processing]]})
Structuring a Clojure retainer
Clojure retainer engagements fail for a predictable reason: the work that takes the most hours produces the least visible output. A core.async topology redesign that eliminates message loss leaves no new feature in the changelog. A Datomic schema evolution that adds three attributes with correct :db/unique constraints leaves no migration script in version control. A clojure.spec authorship sprint that defines 40 specs and instruments 20 functions leaves a test suite that catches more errors — but the engineer who wrote the specs cannot point to a shipped screen or endpoint as evidence of the engagement’s value.
The retainer agreement should define scope across four functional areas with separate hour allocations: feature development (new functions, namespaces, Ring routes, Re-frame event handlers, Datomic transactions for new business capabilities), architecture advisory (core.async topology, STM transaction design, macro system design, protocol and record hierarchy, namespace boundary design), data layer work (Datomic schema evolution, Datalog query optimization, spec authorship and generative test design, transducer pipeline design), and platform maintenance (dependency upgrades, shadow-cljs build configuration, REPL tooling, criterium benchmarking, JVM tuning for the Clojure runtime). Each area should have its own hour allocation so that platform-layer work is not competing with feature development for the same pool of hours.
Work log entries for Clojure retainer work should be explicit about the advisory category, the namespace or component being worked on, the specific Clojure API decisions made, and the before/after metric where one exists. An entry that says “core.async work, 4 hours” does not explain to the client why four hours produced no new feature. An entry that says “core.async topology redesign — payments.pipeline namespace — redesigned intake channel from rendezvous (chan) to bounded (chan 1000) with >!! blocking puts for backpressure; migrated PostgreSQL write step from go blocks to pipeline-async with 8 parallelism to free the go thread pool for I/O-bound operations; message loss under load: 0.3% to 0” explains exactly what was decided, why, and what the business outcome was.
HourTab turns this structured work log into a public retainer dashboard URL that the client can bookmark — a live view of hours logged, remaining allocation, and the work summaries behind each entry. When the CTO asks “what has our Clojure architect been doing for three months?”, the HourTab URL answers with the channel topology decision, the spec authorship sprint, the Datomic schema evolution, and the before/after metrics for each — without requiring a status call, a slide deck, or a separately maintained report. The retainer client reporting problem is solved the same way for Clojure architects as for any other specialist: structured work logs surfaced through a shared URL.
What to log and how to log it
Effective Clojure retainer log entries follow a consistent structure that connects the advisory category to the specific namespace, the specific API decisions made, and the outcome. Five categories cover the majority of Clojure retainer work:
core.async topology. Log the channel type changed (rendezvous to bounded, or dropping buffer), the capacity chosen and why, whether pipeline or pipeline-async was selected and which workload drove the choice, and the before/after throughput or loss metric.
Datomic schema evolution. Log the attributes added (name, :db/valueType, :db/cardinality, :db/unique if applicable), the Datalog queries written or modified, whether pull API patterns replaced N+1 REST calls, and whether transaction functions were added for atomic conditional writes.
clojure.spec authorship. Log the specs defined (s/def names, s/fdef functions instrumented), whether generative tests were run with stest/check and how many test cases, and any edge cases discovered by generative testing that human tests missed.
Macro design. Log the macro written (name, purpose, what repetition it eliminates), verify the expansion with macroexpand-1 output in the log entry, and note whether gensym hygiene was required.
Transducer pipeline design. Log the lazy sequence chain replaced, the transducer stack composed with comp, whether criterium benchmarks were run, and the throughput improvement measured.
Retainer rates for Clojure developers
Clojure developers command a premium over JVM-language peers because the talent pool is smaller, the functional programming depth required for production Clojure systems is significant, and the Datomic, core.async, and spec expertise that high-value retainer engagements demand is concentrated among a small number of practitioners who have spent years working in Clojure-first environments. The rates below reflect 2026 market data for independent Clojure developers and fractional Clojure architects working on production systems.
Hourly rates by experience level
Entry-level Clojure developers (1–3 years): $90–$155/hr. Developers at this level understand persistent data structures, basic sequence operations, REPL-driven development, and simple Ring handlers. They can implement feature work under senior guidance but are not yet designing channel topologies, writing macros, or architecting Datomic schemas. Monthly retainers typically run 10–20 hours for feature development and code review support.
Mid-level Clojure engineers (3–8 years): $145–$260/hr. Engineers at this level have production experience with core.async, clojure.spec, Datomic queries, transducers, and either Reitit or Pedestal. They design channel topologies independently, author specs for new domains, write Datalog queries for complex requirements, and design Re-frame application state. Monthly retainers typically run 15–35 hours covering feature development, architecture advisory, and data layer work.
Senior Clojure architects (8–15 years): $205–$380/hr. Architects at this level have deep Clojure internals knowledge: persistent data structure implementation (HAMT for maps/sets, RRB-trees for vectors), STM retry semantics and contention analysis, core.async scheduler internals and thread pool sizing, macro hygiene and gensym correctness, Datomic log and index structure (EAVT, AEVT, AVET, VAET indexes), ClojureScript compilation model with shadow-cljs, and JVM tuning for the Clojure runtime (GC pressure from short-lived sequence objects, YJIT on GraalVM). Monthly retainers typically run 20–50 hours covering all four engagement categories.
Clojure consulting firms and functional programming studios: $170–$300/hr for team-based engagements. Firms provide more bandwidth, knowledge transfer, and institutional continuity than individual consultants, and typically structure retainers with named leads plus junior support.
Monthly retainer amounts
Monthly retainer amounts for Clojure platform advisory and architecture consulting cluster into two ranges depending on engagement scope. Advisory retainers — covering architecture review, code review, core.async topology consultation, Datomic schema guidance, and async office hours without significant implementation work — typically run $4,500–$8,500 per month. These correspond to 15–30 hours per month at mid-to-senior rates.
Full consulting retainers — covering active implementation alongside advisory, including new namespace development, spec authorship, Datomic transaction function implementation, macro design, ClojureScript Re-frame architecture, and Pedestal interceptor chain design — typically run $12,000–$24,000 per month. These correspond to 35–65 hours per month at mid-to-senior rates and represent the engagement level at which a Clojure architect is functioning as a fractional technical lead for the Clojure portions of the platform.
The retainer pays for itself when it prevents a single architectural mistake from accumulating into a production incident. The payment pipeline message loss in the opening scenario — 0.3% of events dropped under load — was the consequence of a single channel topology decision made early in the system’s life: using a rendezvous channel where a bounded channel with backpressure was required. Fixing it after three weeks of debugging, reconciliation investigation, and Kafka offset investigation consumed far more engineering time than the 18 hours the Clojure consultant on retainer spent designing and implementing the correct topology. Monthly retainer advisory prevents that accumulation before it becomes an incident with a customer-facing impact.
HourTab gives Clojure consultants a public, no-login retainer dashboard URL their clients can bookmark. No client account. No portal. Upload your hours CSV, share the link. Start free →