Use refs and Software Transactional Memory to coordinate related state changes in a multithreaded Clojure application without copying Java lock-ordering problems.
Many Java concurrency bugs appear when two pieces of state must change together but are protected by separate locks, updated in the wrong order, or partially changed after an exception. Clojure refs solve a narrower problem: coordinated synchronous changes to multiple state identities.
Software Transactional Memory (STM): Clojure’s transaction system for refs, where changes inside
dosynccommit together or retry when conflicts occur.
Use refs when the state really has separate identities and the invariant crosses those identities.
Consider a small account model:
1(def checking (ref {:id :checking
2 :balance 500M}))
3
4(def savings (ref {:id :savings
5 :balance 1000M}))
The invariant is not just “each account has a balance.” The invariant is “a transfer debits one account and credits the other as one logical change.”
1(defn balance [account]
2 (:balance @account))
3
4(defn transfer-in-tx! [from to amount]
5 (when (< (:balance @from) amount)
6 (throw (ex-info "Insufficient funds"
7 {:available (:balance @from)
8 :amount amount})))
9 (alter from update :balance - amount)
10 (alter to update :balance + amount))
11
12(defn transfer! [from to amount]
13 (dosync
14 (transfer-in-tx! from to amount)))
If another transaction conflicts, the transaction can retry. That means code inside dosync must be retry-safe.
A Java version often starts with object monitors or explicit locks:
1public void transfer(Account from, Account to, BigDecimal amount) {
2 synchronized (from) {
3 synchronized (to) {
4 from.debit(amount);
5 to.credit(amount);
6 }
7 }
8}
That code must answer lock-order questions. What happens if another thread transfers in the opposite direction? Do all call sites acquire locks in the same order? What if logging or persistence is mixed into the critical section?
Refs move that coordination into a transaction:
| Concern | Java lock design | Clojure ref design |
|---|---|---|
| Cross-account invariant | Manual lock ordering | One dosync transaction |
| Conflict behavior | Blocks or deadlocks if ordered badly | Retries transaction |
| State update shape | Mutates fields | Installs new immutable values |
| Side-effect risk | Effects can occur while locks are held | Effects inside dosync can repeat and should be avoided |
STM does not make the domain model correct for you. It gives the update a safer execution boundary.
You can test the invariant under contention:
1(defn total-balance []
2 (+ (balance checking) (balance savings)))
3
4(defn reset-accounts! []
5 (dosync
6 (ref-set checking {:id :checking :balance 500M})
7 (ref-set savings {:id :savings :balance 1000M})))
8
9(defn run-transfers! []
10 (reset-accounts!)
11 (let [start-total (total-balance)
12 tasks (doall
13 (for [_ (range 100)]
14 (future
15 (transfer! checking savings 1M)
16 (transfer! savings checking 1M))))]
17 (doseq [task tasks]
18 @task)
19 {:checking (balance checking)
20 :savings (balance savings)
21 :total (total-balance)
22 :unchanged? (= start-total (total-balance))}))
The important result is not the exact final split between accounts after symmetrical transfers. The important result is that the total remains stable if every transfer commits as a whole.
Database writes, log appends, emails, and remote calls should not live inside dosync. A transaction body may retry; an external effect cannot be retried safely unless the effect is explicitly idempotent.
Use this structure instead:
1(defn transfer-and-describe! [from to amount]
2 (let [event (dosync
3 (transfer-in-tx! from to amount)
4 {:type :transfer
5 :amount amount
6 :from (:id @from)
7 :to (:id @to)})]
8 (publish-audit-event! event)
9 event))
The transaction computes and commits state. The audit publication happens after commit.
Many designs do not need refs. If the entire invariant fits in one map, a single atom can be simpler:
1(def accounts
2 (atom {:checking {:balance 500M}
3 :savings {:balance 1000M}}))
Use the smallest tool that makes the invariant obvious.
| Use this | When |
|---|---|
| One atom | One application-state value owns the whole invariant |
| Several refs | Separate identities must change together |
| Agent | Changes should be queued asynchronously for one state owner |
| Java lock | A Java object or library requires lock-based access |
The review question is simple: can another thread observe a half-completed domain change? If yes, the state ownership needs to change.