Build a practical decision model for choosing atoms, refs, agents, futures, promises, Java queues, or plain immutable values when translating Java concurrent designs into Clojure.
The most useful comparison between Java and Clojure concurrency is not a feature checklist. It is a design decision: where should mutation live, who owns it, and how do other threads observe progress?
Java often starts with objects that own mutable fields and then adds locking around the dangerous parts. Clojure starts with immutable values and asks you to make stateful places explicit.
Use the smallest concurrency primitive that honestly matches the coordination need.
| If you need… | Prefer | Java analogy | Clojure warning |
|---|---|---|---|
| No shared mutation | Plain immutable values | Immutable DTOs, records, value objects | Do not invent state just to “cache” local calculations. |
| One independent state value | Atom | AtomicReference plus immutable payload |
Keep swap! functions pure and reasonably fast. |
| Several values changed together | Refs with dosync |
Transactional update across multiple protected fields | Do not perform irreversible I/O inside the transaction. |
| Ordered asynchronous updates to one state owner | Agent | Single-threaded executor for a state cell | Handle agent errors explicitly. |
| One result produced later | Future, promise, delay | Future, CompletableFuture, latch |
Make timeout and cancellation expectations explicit. |
| Bounded work handoff | Java queue, executor, or channel abstraction | BlockingQueue, executor service |
Capacity and shutdown are operational design choices. |
| Dynamic per-thread binding | Var binding | ThreadLocal in limited cases |
Be careful across Java thread pools and callbacks. |
This table also shows why there is no single “Clojure replacement” for Java concurrency. The correct choice depends on the invariant.
Most business logic should not know about threads.
1(defn apply-discount [order]
2 (update order :total * 0.9))
3
4(defn mark-reviewed [order reviewer]
5 (assoc order :reviewed-by reviewer
6 :status :reviewed))
These functions are thread-safe because they do not mutate shared state. They accept values and return values. That is the concurrency design: avoid coordination until coordination is actually needed.
When one logical value changes over time, an atom is often the right state holder.
1(def app-state
2 (atom {:open-orders {}
3 :processed-count 0}))
4
5(defn record-order! [order]
6 (swap! app-state
7 (fn [state]
8 (-> state
9 (assoc-in [:open-orders (:id order)] order)
10 (update :processed-count inc)))))
Because both pieces are inside one map, the update is one atomic replacement of the whole state value. That is often simpler than several Java fields protected by the same monitor.
Do not split state into multiple atoms unless the values can change independently. Multiple atoms do not provide an automatic transaction.
If the model has separate identities that must change together, refs make the transaction explicit.
1(def checking (ref {:balance 500M}))
2(def savings (ref {:balance 1000M}))
3
4(defn transfer! [amount]
5 (dosync
6 (alter checking update :balance - amount)
7 (alter savings update :balance + amount)))
This is not just “Clojure syntax for locks.” The transaction describes the invariant: both balances move together or neither change commits.
Keep transaction functions pure and retry-safe. Do not send emails, write audit rows, or call remote services inside dosync.
Agents are useful when updates should happen later, independently, and in order for one state owner.
1(def audit-log (agent []))
2
3(defn enqueue-audit! [event]
4 (send audit-log conj event))
5
6(defn audit-events []
7 @audit-log)
This is closer to a small ordered work queue attached to a state value than to a general-purpose thread pool. If you need bounded queues, backpressure, or explicit worker lifecycle control, choose a queue or executor abstraction instead.
There is no penalty for using Java concurrency when the boundary is already Java-shaped.
1(import '[java.util.concurrent ArrayBlockingQueue TimeUnit])
2
3(def jobs (ArrayBlockingQueue. 100))
4
5(defn submit-job! [job]
6 (.offer jobs job 250 TimeUnit/MILLISECONDS))
7
8(defn take-job! []
9 (.poll jobs 1 TimeUnit/SECONDS))
The key is ownership. If jobs is a boundary for a worker subsystem, this can be clear. If it leaks into business logic everywhere, the application has recreated Java’s shared mutable collection style.
When translating a Java concurrent design, walk it in this order:
| Step | Question | Clojure action |
|---|---|---|
| 1 | Which data is immutable after construction? | Make it ordinary maps, vectors, sets, records, or values. |
| 2 | Which state changes independently? | Put each independent state owner behind an atom or agent. |
| 3 | Which state must change together? | Use one atom holding a combined value or refs with dosync. |
| 4 | Which work crosses time or thread boundaries? | Use futures, promises, agents, executors, queues, or channels deliberately. |
| 5 | Which Java APIs remain mutable? | Wrap them and return immutable data outward. |
This ordering prevents a common migration failure: porting every Java class and lock into Clojure first, then trying to make the result feel functional afterward.
Strong Clojure concurrency code tends to have visible ownership. Weak code hides mutation in vars, Java objects, callbacks, or side-effecting update functions.
| Code smell | Why it is risky | Better direction |
|---|---|---|
| Several atoms updated in sequence | Other threads can observe half the change | Combine state or use refs |
swap! function writes to logs/database |
Retry can duplicate the effect | Return state first, effect after |
| Mutable Java collection inside an atom | Internal mutation bypasses atom semantics | Store immutable Clojure collection |
| Agents used for every async task | No explicit capacity/backpressure policy | Consider executor, queue, or channel |
| Dynamic vars relied on inside Java callbacks | Thread binding may not be present | Pass context explicitly |
Clojure does not make concurrent design automatic. It makes the design smaller and more inspectable when you use values first and references only where state actually lives.