Articles
Clojure Interview Questions: Persistent Data, Atoms, Refs, and State Changes
Practice Clojure interview questions with persistent values, swap! retry traces, atom and ref transfers, and clear boundaries for external side effects.
Two balances live in two atoms. A transfer subtracts ten from the first and adds ten to the second. Each update is atomic, yet another thread can observe a total that is wrong. That is the central distinction in a Clojure state interview: immutable values simplify reasoning, but the reference boundary determines which changes happen together.
This guide uses original traces and small code examples to connect persistent data, atoms, and refs. Official facts come from Clojure's reference documentation and the linked core API, currently labeled Clojure 1.12.4. Inference and design choices are identified separately. There are no candidate reports or claims about an employer's preferred interview questions.
Start with Design a Concurrent Key-Value Store with Batch Writes if you want to rehearse the same issue at system scale: which set of values must change as one operation?
What changes when persistent data is updated?
Official fact: Clojure's persistent collections are immutable and support updates that produce new values while sharing structure. “Persistent” here means preserving previous versions, not automatically saving data to disk. The data-structures reference defines the collection model.
(def before {:account "A" :balance 60})
(def after (assoc before :balance 50))
(:balance before) ; 60
(:balance after) ; 50
This original example has two values. It does not mutate the map named before. Explain that before discussing the performance of structural sharing. A persistent update need not copy an entire large collection, but it still allocates and can retain old structure when previous versions remain reachable.
Design inference: a system keeping every historical snapshot indefinitely can still run out of memory. Immutability is a semantic advantage, not a capacity policy. Decide which versions must remain available, for how long, and whether a durable event history is required separately.
A Java object inside a persistent map introduces another boundary. If a value contains a mutable ArrayList, someone can mutate that list without replacing the map. The map's associations remain persistent; the nested Java object has its own mutability rules. Do not claim that a Clojure collection deeply freezes arbitrary objects placed inside it.
In an interview, distinguish a value from an identity. A bank account is an identity whose state changes over time. A map describing its balance at one instant is a value. Clojure's values-and-change explanation makes that distinction explicit. Your design must choose the reference through which the identity's current value is observed.
Why can swap! run the update function more than once?
Official fact: swap! applies a function to an atom's value and uses compare-and-set to attempt the update. Contention can cause the function to run again. The function must therefore be free of side effects, as described in the atoms reference.
Original trace: stock starts at five. Threads A and B each call a decrement update. Both initially compute four. A commits four. B's compare-and-set no longer matches the value it read, so B computes again from four and commits three. There are two committed decrements but potentially three update-function evaluations.
Now put send-email! inside that update function. B may send an email during the discarded attempt and send another during the successful attempt. Counting successful updates does not count the side effects that escaped earlier. The defect exists even if the final atom value is correct.
Keep the calculation pure:
(def stock (atom 5))
(defn reserve-one [n]
(if (pos? n)
(dec n)
(throw (ex-info "Out of stock" {:stock n}))))
(swap! stock reserve-one)
This sketch demonstrates a state update; it is not a full order-processing protocol. It does not assign an order identifier, durably record a reservation, or guarantee that a later notification is delivered. Describe those requirements separately instead of treating an atom as a database transaction.
A common follow-up replaces swap! with a read and reset!. If two threads read five and both reset to four, one decrement is lost. reset! deliberately installs a value; it does not make the earlier read and calculation atomic. Use it when replacing the entire independent state is the intended operation, not as a shortcut around compare-and-set reasoning.
The successful result of swap! is useful, but another update can happen immediately afterward. Reading @stock later may return a different value. If your answer needs the old and new values belonging to the same update, discuss swap-vals! rather than pairing independent reads and hoping no other thread intervenes.
Can two atoms implement an atomic transfer?
Here is the broken original sketch:
(def a (atom 60))
(def b (atom 40))
(swap! a - 10)
(swap! b + 10)
Each call protects one atom. Together they do not define one atomic change. An observer between the calls can read A as 50 and B as 40. An exception between them can leave that state in place. Even an observer reading both without coordination can combine values from different instants.
| Boundary | Before | Intermediate observation | After |
|---|---|---|---|
| Two separate atoms | A=60, B=40 | A=50, B=40 is possible | A=50, B=50 |
| One aggregate atom | {A 60, B 40} | No partially installed map | {A 50, B 50} |
| Two refs in one transaction | A=60, B=40 | Transactional readers see a consistent snapshot | A=50, B=50 |
One valid alternative is a single atom containing the entire invariant:
(def accounts (atom {:a 60 :b 40}))
(swap! accounts
(fn [m]
(if (>= (:a m) 10)
(-> m (update :a - 10) (update :b + 10))
(throw (ex-info "Insufficient funds" {})))))
Design inference: this works well when the coordinated data naturally forms one independently updated state value. It can become a contention point if unrelated updates all share that atom. Do not split the atom merely to improve parallelism before identifying which invariants would then cross reference boundaries.
The distinction is architectural, not stylistic. A single atom is not automatically inferior to refs, and refs are not automatically necessary because an operation mentions two keys. Count independent reference locations, not the number of fields in a map.
What does dosync add for refs?
Official fact: refs coordinate changes through Clojure's software transactional memory. Changes occur in a transaction, conflicts can trigger retries, and transactional reads use a consistent snapshot. See refs and transactions.
(def a (ref 60))
(def b (ref 40))
(defn transfer! [amount]
(dosync
(when (or (neg? amount) (< @a amount))
(throw (ex-info "Invalid transfer" {:amount amount})))
(alter a - amount)
(alter b + amount)))
(transfer! 10)
(dosync [@a @b]) ; [50 50]
This original exercise uses integer amounts and two in-process balances. The final read is transactional too. Outside dosync, separate dereferences should not be described as one coherent multi-reference snapshot. A transfer test that reads the two accounts independently during concurrent updates may misdiagnose its own observation.
An exception aborts the transaction's ref changes. It does not roll back an HTTP call made inside the transaction. Design recommendation: keep external effects out of retryable transaction bodies. Sending a notification after a successful transaction avoids speculative duplicate sends, but still leaves a crash window between commit and notification. A durable outbox or equivalent integrated protocol is required when eventual delivery is a business invariant.
STM also does not make process memory durable. Restarting the process loses these refs unless the application reconstructs them from a durable source. Use this example to explain atomicity and isolation, not to claim production banking guarantees.
When are alter, commute, and ensure appropriate?
Use alter when the update depends on the transaction's current value and conflicts should protect that read-modify-write decision. A balance check followed by a debit is a straightforward case. Do not replace it with a more permissive operation solely to reduce retries.
The official core API describes commute for operations whose ordering is compatible with commutative updates and ensure for protecting a ref read during a transaction. The names express different contracts, not interchangeable optimizations.
Original exercise: a counter records how many independent events occurred. Adding one for each event commutes with other additions, provided no acceptance decision depends on the counter being below a limit. Now change the requirement to “accept only the first 100 events.” The operation includes a bound-sensitive decision. Simply using commute for the increment does not establish that invariant.
For ensure, suppose a transaction changes a reservation ref only if another ref says the account is active. If that status must remain protected while the reservation commits, explain the read dependency explicitly. Reading a status from a snapshot and modifying a different ref is not automatically a serializable proof for every cross-reference constraint.
This is where vague claims that STM makes all concurrency safe break down. Specify which refs are read, altered, commuted, or ensured; which invariant those choices protect; and which external states are outside STM. A small dependency diagram gives a reviewer more confidence than a long list of primitive names.
How would you test the state model?
First test pure functions independently of references. Given the same account map and amount, the transfer calculation should return the same result and preserve the total. Include negative amounts, insufficient funds, and zero. This narrows concurrency debugging to the actual commit mechanism.
Then create concurrent updates behind a start barrier, join all workers, and assert the final invariant. For the two-ref transfer, observe paired balances inside dosync. Check nonnegative balances and total conservation under opposing transfers. Do not merely assert that no exception was printed.
To expose the broken two-atom design deterministically, pause after the debit and before the credit. Read the intermediate values and record the incorrect total. This proves the missing atomic boundary without depending on a rare interleaving. Similarly, demonstrate a lost update by making two threads calculate from the same old value before resetting.
For retryable functions, use controlled contention to count evaluations separately from committed updates. Instrumentation in a test can reveal re-execution; it does not justify production I/O inside the function. Finally, test mutable Java values separately so an apparently immutable outer map cannot conceal an uncontrolled inner writer.
Related practice for concurrency explanations
| Question | Clojure-focused decision |
|---|---|
| Java Core Deep Dive: Concurrent Collections and Java 8 Features | Separate container safety from a multi-value invariant. |
| Design a Concurrent Key-Value Store with Batch Writes | Decide whether a batch installs as one state change. |
| Reviewing a Freight-Scheduler Codebase: Bugs, Data Structures, and Concurrency | Trace reads that influence updates to other state. |
| Debug a Concurrent Job Scheduler | Identify effects that cannot safely repeat. |
| In-Memory Key-Value Store with Crash Recovery from a Log and Concurrent Access | Distinguish persistent collections from durable recovery. |
Use the batch-write exercise to explain one failed interleaving, one correct invariant boundary, and one remaining durability limitation. Those three statements make the difference between knowing the vocabulary and defending a state model.