Use Clojure agents for simple ordered in-process side-effect workflows, while recognizing when Java executors, queues, or durable infrastructure are the safer choice.
Agents can be useful for side effects, but only when the effect fits the agent model: in-process, asynchronous, ordered per agent, and not requiring durable delivery by itself. That is narrower than “background processing” and much narrower than “all I/O.”
Agent action: A function sent to an agent with
sendorsend-off; it receives the current agent state and returns the next state.
The agent’s state is still important. Do not treat an agent as a hidden thread launcher with no ownership model.
Suppose you want to collect recent audit events and occasionally flush them:
1(def audit-buffer
2 (agent []))
3
4(defn remember-audit [events event]
5 (->> (conj events event)
6 (take-last 500)
7 vec))
8
9(defn record-audit! [event]
10 (send audit-buffer remember-audit event))
This is a good agent use case because one state owner holds an ordered buffer and callers do not need an immediate result.
| Function | Use when | Avoid when |
|---|---|---|
send |
Action is short and CPU-bound | Action may block on slow I/O |
send-off |
Action may block | You are using it to hide unbounded work |
For I/O, prefer send-off if an agent is still the right tool:
1(defn flush-events [events write-batch!]
2 (when (seq events)
3 (write-batch! events))
4 [])
5
6(defn flush-audit! [write-batch!]
7 (send-off audit-buffer flush-events write-batch!))
The function write-batch! is injected. That makes the effect explicit and testable.
By default, an agent that throws enters a failed state and stops accepting further work until the error is handled. Do not discover this only in production.
1(def audit-buffer
2 (agent []
3 :error-handler
4 (fn [_agent ex]
5 (println "audit buffer failed:" (.getMessage ex)))))
If it is acceptable to continue after a failed action, choose that explicitly:
1(def best-effort-audit
2 (agent []
3 :error-mode :continue
4 :error-handler
5 (fn [_agent ex]
6 (println "dropped audit event:" (.getMessage ex)))))
Use :continue for best-effort telemetry, not for critical domain workflows.
Java engineers already know that “async” is not one requirement. Capacity, durability, retry, ordering, and shutdown all matter.
| Requirement | Agent fit | Better fit when stronger guarantees are needed |
|---|---|---|
| Ordered in-memory state update | Strong | Agent is often enough |
| Bounded backpressure | Weak | BlockingQueue, executor, or channel |
| Durable delivery | Not enough | Database outbox or message broker |
| Retry with dead-letter handling | Not enough | Queue or workflow system |
| Integration with Java library callbacks | Boundary use only | Java executor or library-native API |
Agents are good for small in-process ownership problems. They are not a substitute for operational infrastructure.
Tests that depend on agent completion should wait:
1(defn record-and-read! [events]
2 (doseq [event events]
3 (record-audit! event))
4 (await audit-buffer)
5 @audit-buffer)
Avoid using Thread/sleep in tests. Sleep guesses at timing; await expresses the dependency.