Design a small concurrent Clojure workflow that uses atoms, refs, agents, futures, and Java queues only where their ownership and coordination responsibilities are clear.
Real applications rarely use exactly one concurrency primitive. They combine immutable values, atoms, refs, agents, futures, Java queues, executors, and database transactions. The challenge is not combining tools. The challenge is preventing each tool from owning the wrong responsibility.
For Java engineers, the familiar failure mode is a design where every object is mutable, every method can run on many threads, and correctness depends on remembering lock rules. Clojure gives you a cleaner alternative if you make ownership explicit.
Imagine an order intake service:
That design naturally uses more than one tool.
| Responsibility | Good Clojure shape | Why |
|---|---|---|
| Request validation | Pure functions | No shared state needed |
| Request counters | Atom | One independent summary value |
| Inventory reservation | Refs with dosync |
Several item quantities must change together |
| Recent audit trail | Agent | Ordered background state updates |
| Fulfillment handoff | Java queue or executor | Capacity, shutdown, and worker lifecycle matter |
The table matters more than the code. Each state owner has one job.
1(import '[java.util.concurrent ArrayBlockingQueue])
2
3(def metrics
4 (atom {:accepted 0
5 :rejected 0}))
6
7(def inventory
8 {:sku/a (ref 20)
9 :sku/b (ref 10)})
10
11(def audit-log
12 (agent []))
13
14(def fulfillment-queue
15 (ArrayBlockingQueue. 100))
The atom owns aggregate metrics. The refs own coordinated inventory quantities. The agent owns an in-memory recent audit log. The queue owns the operational handoff to workers.
Pure functions make the concurrent parts smaller:
1(defn valid-order? [order]
2 (and (some? (:id order))
3 (seq (:items order))))
4
5(defn order-event [status order]
6 {:type :order/processed
7 :status status
8 :order-id (:id order)})
These functions do not know about atoms, refs, agents, or queues. That makes them easy to test and safe to call from any thread.
Inventory reservation crosses several state identities:
1(defn reserve-inventory! [items]
2 (dosync
3 (doseq [[sku qty] items
4 :let [stock (get inventory sku)]]
5 (when (nil? stock)
6 (throw (ex-info "Unknown SKU" {:sku sku})))
7 (when (< @stock qty)
8 (throw (ex-info "Insufficient inventory"
9 {:sku sku
10 :requested qty
11 :available @stock}))))
12 (doseq [[sku qty] items]
13 (alter (get inventory sku) - qty))))
14
15(defn release-inventory! [items]
16 (dosync
17 (doseq [[sku qty] items]
18 (alter (get inventory sku) + qty))))
Both validation and update happen inside the transaction because they read and write the coordinated refs. External effects stay outside.
1(defn remember-audit [events event]
2 (->> (conj events event)
3 (take-last 200)
4 vec))
5
6(defn submit-fulfillment! [order]
7 (when-not (.offer fulfillment-queue order)
8 (throw (ex-info "Fulfillment queue is full"
9 {:order-id (:id order)
10 :reason :fulfillment-backpressure}))))
11
12(defn accept-order! [order]
13 (if-not (valid-order? order)
14 (do
15 (swap! metrics update :rejected inc)
16 (send audit-log remember-audit (order-event :rejected order))
17 {:status :rejected})
18 (do
19 (reserve-inventory! (:items order))
20 (try
21 (submit-fulfillment! order)
22 (swap! metrics update :accepted inc)
23 (send audit-log remember-audit (order-event :accepted order))
24 {:status :accepted}
25 (catch clojure.lang.ExceptionInfo ex
26 (release-inventory! (:items order))
27 (swap! metrics update :rejected inc)
28 (send audit-log remember-audit
29 (assoc (order-event :rejected order)
30 :reason :fulfillment-backpressure))
31 {:status :rejected
32 :reason (:reason (ex-data ex))})))))
This example is intentionally small, but it shows the boundary choices:
| Operation | Why it happens there |
|---|---|
| Validation first | Avoids touching shared state for bad requests |
| Inventory reservation before fulfillment | Workers should not receive orders that cannot be reserved |
| Queue offer after reservation | Fulfillment sees only reserved orders; a queue failure compensates by releasing inventory |
| Metrics atom update after final decision | Metrics summarize outcomes but do not own business invariants |
| Audit agent after decision | Audit trail can lag the response |
In production, you may want stronger transactional guarantees between inventory, audit, metrics, and fulfillment. That usually means a durable database transaction or event log. In-process Clojure references are not a substitute for durable workflow design.
When combining primitives, review the design with ownership questions:
| Question | Healthy answer |
|---|---|
| Who owns this state? | One atom, ref set, agent, queue, database, or Java component |
| Can another thread see half a domain change? | No, because related state is combined or transactional |
| Can an effect repeat because of retry? | No, effects are outside swap! and dosync |
| Is queue capacity handled? | Yes, .offer failure has an explicit branch and compensation path |
| Can tests wait for asynchronous work? | Yes, await or queue-drain logic exists where needed |
The goal is not to use every Clojure primitive. The goal is to make the concurrent parts small enough that a reviewer can explain them without guessing.