Learn what counts as a side effect in Clojure, why retryable atom and STM updates make effects risky, and how to separate pure decisions from observable work.
A side effect is any observable interaction beyond returning a value: mutating state, logging, writing a file, calling a database, sending a message, reading the clock, generating a random ID, or making an HTTP request. Java programs often mix those effects into service methods. In Clojure, that habit becomes dangerous when the method body moves inside swap!, dosync, or an asynchronous action.
Side effect: Work that changes or observes something outside the function’s return value, such as I/O, mutation, time, randomness, logging, or a network call.
The first concurrency skill is not picking atoms, refs, or agents. It is knowing which parts of the code should be pure.
Start with a function that only decides what should happen:
1(defn classify-payment [payment]
2 (cond
3 (not= :authorized (:status payment)) :reject
4 (> (:amount payment) 10000M) :manual-review
5 :else :capture))
This function is safe to call from any thread. It does not log, mutate, sleep, call a payment gateway, or inspect global state. The effectful boundary can call it and then do the work:
1(defn handle-payment! [gateway payment]
2 (case (classify-payment payment)
3 :reject
4 {:status :rejected}
5
6 :manual-review
7 (do
8 (enqueue-review! payment)
9 {:status :queued})
10
11 :capture
12 (do
13 (capture-payment! gateway payment)
14 {:status :captured})))
The exclamation mark in handle-payment! is a convention: this function performs effects. The pure function does not need one.
swap!An atom update function can run more than once if another thread changes the atom first.
1(def orders (atom {}))
2
3(defn bad-record-order! [order]
4 (swap! orders
5 (fn [current]
6 (send-confirmation-email! order)
7 (assoc current (:id order) order))))
That email can be sent twice if swap! retries. A safer shape records the need for an effect as data, then lets a boundary drain that effect deliberately:
1(def order-state
2 (atom {:orders {}
3 :email-outbox []}))
4
5(defn record-order! [order]
6 (swap! order-state
7 (fn [state]
8 (if (contains? (:orders state) (:id order))
9 state
10 (-> state
11 (assoc-in [:orders (:id order)] order)
12 (update :email-outbox conj
13 {:type :order/confirmation
14 :order-id (:id order)}))))))
15
16(defn send-pending-confirmations! [send-email!]
17 (let [messages (loop []
18 (let [state @order-state
19 messages (:email-outbox state)]
20 (if (compare-and-set! order-state
21 state
22 (assoc state :email-outbox []))
23 messages
24 (recur))))]
25 (doseq [message messages]
26 (send-email! message))))
This in-memory outbox is still only a teaching example. When exact-once or at-least-once effects matter across process crashes, use a database constraint, idempotency key, outbox table, or durable queue. An atom alone is not a delivery system.
dosyncSTM transactions can retry too:
1(def account (ref {:balance 100M}))
2
3(defn bad-withdraw! [amount]
4 (dosync
5 (alter account update :balance - amount)
6 (append-audit-row! {:type :withdrawal
7 :amount amount})))
The audit row can be appended more than once. Instead, compute an event inside the transaction and publish it after commit:
1(defn withdraw! [amount]
2 (let [event (dosync
3 (alter account update :balance - amount)
4 {:type :withdrawal
5 :amount amount
6 :balance (:balance @account)})]
7 (append-audit-row! event)
8 event))
If the audit event and balance update must commit atomically across process crashes, use a database transaction or outbox pattern. Clojure refs are in-memory coordination, not durable storage.
| Java habit | Safer Clojure review |
|---|---|
| Service method mutates fields and writes logs | Separate the pure decision from the effectful boundary. |
synchronized block performs database write |
Avoid holding state coordination while slow I/O runs; use a database transaction when the database owns the invariant. |
| Atomic update callback calls a service | Keep retryable callbacks pure so service calls cannot repeat. |
| Method reads the clock deep inside logic | Pass time in as boundary data so tests and retries stay deterministic. |
| Random ID generated during state update | Generate IDs at the boundary or use durable idempotency. |
The Clojure habit is to pass facts into pure functions and perform effects in narrow namespaces or workflow functions whose names make the effect visible.
Before making concurrent Clojure code more complex, ask:
| Question | Why it matters |
|---|---|
| Can this function return the same result for the same inputs? | If yes, keep it pure and test it directly. |
Is this code inside swap! or dosync? |
Effects may repeat under retry. |
| Does this operation talk to the outside world? | Put it at a boundary and make failure explicit. |
| Does the effect need exactly-once behavior? | Use durable infrastructure, not only in-memory references. |
| Can the caller tolerate eventual completion? | If yes, an agent or queue may be appropriate. |
Good Clojure concurrency starts with boring pure code and a small number of explicit effect boundaries.