Browse Learn Clojure Foundations as a Java Developer

Combine Clojure Concurrency Primitives Without Mixing Responsibilities

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.

A Small Order Workflow

Imagine an order intake service:

  • request handlers validate orders
  • a counter tracks accepted and rejected orders
  • inventory reservations must update several items together
  • audit events can be recorded after the response
  • a worker queue handles slower fulfillment

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.

Define The State Owners

 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.

Keep Pure Logic Separate

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.

Coordinate Inventory With Refs

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.

Compose The Workflow

 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.

Review The Ownership Boundaries

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.

Knowledge Check

### Why does the order workflow use refs for inventory instead of only an atom for metrics? - [x] Inventory reservation coordinates multiple item quantities as one domain invariant - [ ] Refs are always faster than atoms - [ ] Metrics cannot be stored in maps - [ ] Agents cannot hold vectors > **Explanation:** Metrics are one independent summary value, so an atom fits. Inventory reservation may need several stock refs to change together, so STM is appropriate. ### Why is fulfillment submitted after inventory reservation? - [x] A worker should not receive an order whose inventory was not reserved - [ ] Java queues can only hold completed transactions - [ ] Agents require a queue before every send - [ ] `swap!` must always run last > **Explanation:** The queue is an operational handoff. Sending work before the state invariant is secured can make workers process invalid orders. ### What is the main review concern when combining atoms, refs, agents, and queues? - [x] Each tool must have a clear ownership and coordination responsibility - [ ] Every function must use all primitives - [ ] Java interop must be avoided completely - [ ] All asynchronous work must be hidden > **Explanation:** Combining tools is normal. Confusion starts when ownership is unclear and several primitives appear to control the same state or effect.
Revised on Saturday, May 23, 2026