Browse Learn Clojure Foundations as a Java Developer

Understand Side Effects Before Adding Concurrency

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.

Pure Decision, Effectful Boundary

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.

Why Effects Are Risky In 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.

Why Effects Are Risky In dosync

STM 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-To-Clojure Translation

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.

Review Checklist

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.

Knowledge Check

### Why is it risky to send an email inside the function passed to `swap!`? - [x] The function may run more than once if the atom update retries - [ ] `swap!` cannot update maps - [ ] Emails are not allowed from Clojure - [ ] Atoms always run on a background thread > **Explanation:** `swap!` can retry the update function under contention. Any side effect inside that function can repeat even though only one final atom state is installed. ### What is the best role for a pure function in concurrent code? - [x] Calculate a decision from input data without interacting with the outside world - [ ] Write logs in a synchronized block - [ ] Hide database calls from tests - [ ] Replace all queues and executors > **Explanation:** Pure functions are safe to call from any thread and easy to test. Effectful boundary functions can call them before performing I/O. ### When exact-once external effects matter, what should you usually use? - [x] A durable mechanism such as a database constraint, outbox, or idempotent queue - [ ] A plain atom only - [ ] A longer `Thread/sleep` - [ ] A comment saying the effect should happen once > **Explanation:** In-memory Clojure references coordinate process-local state. Exactly-once external effects need durable, operational guarantees.
Revised on Saturday, May 23, 2026