Browse Learn Clojure Foundations as a Java Developer

Choose Clojure Concurrency Primitives Instead of Locks

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.

The Decision Model

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.

Start With Values

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.

Use Atoms For Independent State

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.

Use Refs For Coordinated State

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.

Use Agents For Ordered Background State

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.

Keep Java At Boundaries

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.

Refactoring A Java Design

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.

What To Watch In Code Review

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.

Knowledge Check

### Which Clojure primitive is the best first choice for one independent state value updated synchronously? - [x] Atom - [ ] Ref - [ ] Agent - [ ] Dynamic var > **Explanation:** Atoms are intended for independent synchronous state changes. Refs coordinate multiple references, and agents process asynchronous updates. ### A transfer must debit one account and credit another as one invariant. Which Clojure shape fits best? - [x] Refs updated inside one `dosync` transaction - [ ] Two atoms updated one after the other - [ ] A dynamic var for each account - [ ] A lazy sequence of balances > **Explanation:** Two separate atom updates can expose a partial state. Refs inside `dosync` express the coordinated transaction. ### What is the main risk of translating Java concurrency code mechanically into Clojure? - [x] Recreating shared mutable object design instead of isolating state behind values and references - [ ] Losing all access to JVM threads - [ ] Making immutable values unsafe - [ ] Preventing Java interop > **Explanation:** Clojure can call Java concurrency APIs, but idiomatic design usually reduces shared mutation rather than copying Java's class-and-lock structure.
Revised on Saturday, May 23, 2026