Browse Learn Clojure Foundations as a Java Developer

Run Ordered Background Work with Agents

Use Clojure agents for ordered background state updates, understand when they differ from Java executors and queues, and handle failures without hiding operational risk.

Agents are useful when work can happen later and each update belongs to one state owner. They are not a replacement for every Java executor, queue, scheduler, or messaging system. Their strength is ordered asynchronous state change.

Agent: A Clojure reference type that queues functions and applies them asynchronously to one state value.

For Java engineers, an agent feels a little like a single stateful actor or a serial executor attached to one value. That analogy is useful, but agents still use Clojure values and update functions.

A Background Audit Buffer

Suppose a request handler should record audit events without blocking on the immediate state update. An agent can own a vector of recent events:

 1(def audit-events
 2  (agent []))
 3
 4(defn remember-event [events event]
 5  (->> (conj events event)
 6       (take-last 100)
 7       vec))
 8
 9(defn record-audit! [event]
10  (send audit-events remember-event event))
11
12(defn recent-audit-events []
13  @audit-events)

record-audit! returns quickly after enqueueing the action. The agent applies actions later, in order, to its own state.

What Agents Are Good For

Agents fit a specific shape:

Good fit Why
Recent event buffer Ordered updates to one state value
In-memory audit accumulator Callers do not need the result immediately
Background refresh state New state can replace old state later
Low-volume write-behind task Work is independent enough to queue

They are weaker fits when capacity, durability, retries, or distributed delivery are the main requirement. Use a real queue, executor, scheduler, database, or message broker when the operational contract demands it.

Choose send Or send-off

Clojure provides two dispatch functions:

Function Intended work Java mental model
send CPU-bound or short actions Shared fixed-size executor
send-off Potentially blocking actions Executor that can grow for blocking work

Keep agent actions short when possible. If an action writes to a slow service or blocks on I/O, send-off is usually more appropriate than send.

1(defn refresh-cache [state fetch-latest]
2  (assoc state :latest (fetch-latest)))
3
4(def cache-state
5  (agent {:latest nil}))
6
7(defn refresh-cache! [fetch-latest]
8  (send-off cache-state refresh-cache fetch-latest))

The function fetch-latest is passed in so the boundary is explicit. The agent still owns only the resulting state.

Handle Agent Failures

If an action throws, an agent enters a failed state by default and stops processing later actions until the error is handled. That behavior is useful because silent background failure is worse than visible failure.

1(def audit-events
2  (agent []
3         :error-mode :continue
4         :error-handler
5         (fn [_agent ex]
6           (println "audit agent failed:" (.getMessage ex)))))

Use :continue only when dropping or skipping a failed action is acceptable. For critical work, use a queue with explicit retry and persistence instead.

Shutdown And Tests

Because agent work is asynchronous, tests and shutdown hooks need to wait when they depend on completed work:

1(defn record-all! [events]
2  (doseq [event events]
3    (record-audit! event))
4  (await audit-events)
5  @audit-events)

await is useful in tests, command-line scripts, and controlled shutdown paths. It should not become a routine request-path synchronization tool. If callers always need to wait for the result, the design is probably synchronous state, not an agent.

Agents Versus Java Tools

Agents are often compared with Java executors, but the ownership model is different.

Need Agent Java executor or queue
Ordered state updates for one owner Strong fit Possible, but more boilerplate
Bounded backpressure Weak fit Strong fit
Durable delivery Not enough Use database, broker, or durable queue
Immediate result Weak fit Future or CompletableFuture may fit
Java framework integration Boundary only Strong fit

The practical rule: use agents for simple in-process asynchronous state ownership. Use Java operational primitives for operational guarantees.

Knowledge Check

### What makes an agent different from an atom? - [x] Agent actions are queued and applied asynchronously - [ ] Agents can only hold numbers - [ ] Agents require manual locks - [ ] Atoms are always durable > **Explanation:** Atoms update synchronously through `swap!` and `reset!`. Agents queue functions that run later against one state value. ### When is `send-off` usually a better fit than `send`? - [x] When the action may block on I/O or another slow operation - [ ] When the update must be synchronous - [ ] When the state value is a map - [ ] When the action should run inside STM > **Explanation:** `send` is intended for shorter CPU-bound actions. `send-off` is designed for actions that may block. ### Why should critical durable work not rely only on an in-memory agent? - [x] Agent state is in-process and does not provide durable delivery or persistent retry - [ ] Agents cannot process functions - [ ] Agents always run on the caller's thread - [ ] Agents prevent Java interop > **Explanation:** Agents are useful for in-process asynchronous state updates. Critical durable workflows need explicit persistence, retry, and recovery semantics.
Revised on Saturday, May 23, 2026