Replace hidden Java object mutation with explicit Clojure value transitions, controlled state references, and reviewable boundaries for atoms, refs, agents, databases, and queues.
State does not disappear when Java code moves to Clojure. It changes shape. Instead of spreading mutation across object fields, setters, caches, and callbacks, Clojure asks you to separate ordinary value transformations from the few places where state actually changes.
For Java engineers, the important distinction is local transformation versus shared state. Most migrated code should return new values. Only true shared, time-varying state needs atoms, refs, agents, databases, queues, or other effectful boundaries.
Before choosing a Clojure state tool, classify what kind of state you have.
| State kind | Java shape | Clojure direction |
|---|---|---|
| Local calculation | Mutable local variable or builder | let, reduce, assoc, update, or threading macros. |
| Domain transition | Setter changes status or fields | Function that returns a new domain value. |
| Independent shared value | Counter, in-memory toggle, small cache | Atom if in-memory state is genuinely needed. |
| Coordinated shared values | Multiple balances or related refs | Ref and STM when in-memory coordinated updates are the right model. |
| Background state | Async work queue, log collector | Agent or external queue depending on operational needs. |
| Durable truth | Database or event log | Keep as database/queue boundary; do not replace with in-memory state. |
This table is more important than memorizing atoms, refs, and agents. The wrong tool usually comes from misclassifying state.
A Java object may mutate itself to record a business transition.
1public void suspend(String reason, Instant at) {
2 this.status = AccountStatus.SUSPENDED;
3 this.suspensionReason = reason;
4 this.updatedAt = at;
5}
In Clojure, express the transition as a value transformation.
1(defn suspend-account [account reason at]
2 (assoc account
3 :account/status :suspended
4 :account/suspension-reason reason
5 :account/updated-at at))
This does not update the database. It creates the next domain value. A repository or service boundary can decide whether and how that value is persisted.
Atoms are useful for independent, synchronous, in-memory state. They are not local variables with a different syntax.
1(def feature-state
2 (atom {:migration/enabled? false
3 :migration/sample-rate 0.0}))
4
5(defn enable-migration! [sample-rate]
6 (swap! feature-state assoc
7 :migration/enabled? true
8 :migration/sample-rate sample-rate))
This can be reasonable for local development, test harnesses, or small process-local switches. It is not a replacement for a distributed configuration system if multiple JVMs must agree.
Refs and software transactional memory can coordinate in-memory changes to multiple references.
1(def debit-total (ref 0M))
2(def credit-total (ref 0M))
3
4(defn record-transfer! [amount]
5 (dosync
6 (alter debit-total + amount)
7 (alter credit-total + amount)))
Use this model only when the coordinated state is truly in-memory and process-local. If the truth lives in a database, use database transactions. Do not move durable consistency into refs just because refs are available.
A practical migration often has this shape:
1(defn next-policy-state [policy event at]
2 (case (:event/type event)
3 :policy/renewed
4 (assoc policy :policy/status :renewed :policy/renewed-at at)
5
6 :policy/cancelled
7 (assoc policy :policy/status :cancelled :policy/cancelled-at at)
8
9 policy))
10
11(defn handle-policy-event! [{:keys [load-policy save-policy! now]} event]
12 (let [policy (load-policy (:policy/id event))
13 next-policy (next-policy-state policy event (now))]
14 (save-policy! next-policy)
15 next-policy))
next-policy-state is pure. handle-policy-event! is effectful and marked with !. This split makes the migration reviewable: test the transition thoroughly, then separately test the adapter that loads and saves.
| Migration smell | Better shape |
|---|---|
| One global atom holds the whole application model | Pass values through functions and persist durable state in real storage. |
| Atoms used inside pure transformations | Use reduce, let, assoc, and update. |
| Database state mirrored in process memory without invalidation | Keep database as source of truth or add explicit cache semantics. |
Side-effecting functions lack ! suffix |
Mark effectful boundaries for reviewers. |
| Tests rely on shared mutable fixtures | Pass values directly or reset controlled test state explicitly. |
Clojure can write mutable, hard-to-debug systems too. The discipline is making state visible and narrow.
! to effectful Clojure functions and keep pure helpers unmarked.! so reviewers see the boundary.