Diagnose the bugs caused by shared mutable objects, then learn how Clojure uses immutable values and explicit references to make concurrent state changes easier to reason about.
Shared mutable state is data that more than one thread can read and change. Java engineers learn to protect it with synchronization, atomic classes, concurrent collections, or careful ownership rules. The hard part is that the ownership rule often lives in a comment, a convention, or one maintainer’s memory.
Clojure reduces the surface area of this problem. Most data is immutable and can be shared safely. When identity must change, the changing place is represented explicitly by an atom, ref, agent, Var, channel, database row, queue, or some other boundary.
| Problem | Java example | Why it hurts |
|---|---|---|
| Lost update | Two threads read count = 41, both write 42. |
The program drops work without throwing an exception. |
| Aliasing | Two services hold the same mutable List. |
A change in one component surprises another component. |
| Broken invariant | Debit succeeds but credit fails. | Related facts no longer agree. |
| Stale read | One thread does not observe another thread’s write when expected. | The bug appears only under timing, CPU, or deployment differences. |
| Lock coupling | Correctness depends on acquiring several locks in the right order. | Maintenance changes can introduce deadlocks. |
The Clojure answer is not “never change anything.” It is “make the changing identity small, explicit, and updated by functions over immutable values.”
Start by writing the rule as a pure function. This function has no global state, no lock, and no hidden mutable collection.
1(defn apply-payment [inv p]
2 (-> inv
3 (update :paid + (:amount p))
4 (update :events conj
5 {:type :paid
6 :payment (:id p)})))
That function can be tested with plain maps. It can also be used safely inside a state update because it returns a new invoice value instead of mutating the old one.
1(def invoices
2 (atom
3 {"100"
4 {:total 125M
5 :paid 0M
6 :events []}}))
7
8(defn record-payment! [id payment]
9 (swap! invoices
10 update
11 id
12 apply-payment
13 payment))
The atom is the changing reference. The invoice map is an immutable value. That separation is the point.
| Need | Prefer |
|---|---|
| One independent changing value | Atom |
| Several identities must change together | Ref plus dosync, or one larger immutable value |
| Ordered asynchronous updates to one value | Agent |
| Work should move between stages | Channel or queue |
| Durable shared state | Database transaction or external store |
| Per-request context | Explicit function arguments; dynamic Vars only for narrow cross-cutting cases |
Do not put every piece of state in an atom. An atom is correct when the whole update can be described as a function from old value to new value for one independent identity.
swap! BoundaryBecause swap! can retry, the update function must not perform irreversible work.
1;; Avoid: the email could be sent more than once if swap! retries.
2(swap! invoices
3 (fn [state]
4 (send-email! customer)
5 (update state
6 invoice-id
7 apply-payment
8 payment)))
Keep side effects outside the retryable function:
1(let [result (swap! invoices
2 update
3 invoice-id
4 apply-payment
5 payment)]
6 (send-confirmation!
7 invoice-id
8 payment)
9 result)
For more complex workflows, store an event or command as data during the state transition, then have a separate effect boundary process it.