Structure Clojure code so pure transformation logic stays separate from HTTP, database, filesystem, logging, and messaging effects, especially under concurrent execution.
Side-effect isolation is an architectural habit, not a library. Java teams often express it as layered services, ports and adapters, or hexagonal architecture. In Clojure, the same idea is lighter: keep data transformation functions pure, then call effectful functions from a thin boundary.
The payoff is larger in concurrent code because pure functions do not need locks, retries, thread locals, or special test harnesses.
A common pattern is to make the core function return a plan:
1(defn plan-user-notification [user event]
2 (cond
3 (not (:email user))
4 {:action :skip
5 :reason :missing-email}
6
7 (= :account/locked (:type event))
8 {:action :send-email
9 :template :account-locked
10 :to (:email user)}
11
12 :else
13 {:action :record-only}))
The executor performs the effect:
1(defn execute-notification! [mailer plan]
2 (case (:action plan)
3 :skip
4 plan
5
6 :record-only
7 (do
8 (record-notification! plan)
9 plan)
10
11 :send-email
12 (do
13 (send-email! mailer plan)
14 (record-notification! plan)
15 plan)))
The pure function can be tested with simple maps. The effectful function can be integration-tested with a fake mailer or controlled boundary.
Clojure does not force package-private fields or service classes, so naming and namespace boundaries matter.
| Namespace shape | Responsibility |
|---|---|
myapp.orders.rules |
Pure decisions and transformations |
myapp.orders.state |
Atoms, refs, and in-memory state ownership |
myapp.orders.io |
Database, HTTP, filesystem, and message broker effects |
myapp.orders.workflow |
Orchestration that calls pure logic and effects in a visible order |
myapp.orders.test-support |
Fakes and fixtures for boundary tests |
This is not bureaucracy. It prevents the most common Clojure migration problem: hiding Java-style side effects inside any function that happens to have a map parameter.
Passing boundary functions as arguments is often simpler than building a class hierarchy.
1(defn close-ticket! [{:keys [save! notify! now]} ticket]
2 (let [closed-ticket (assoc ticket
3 :status :closed
4 :closed-at (now))]
5 (save! closed-ticket)
6 (notify! {:type :ticket/closed
7 :ticket-id (:id ticket)})
8 closed-ticket))
In production, save!, notify!, and now are real effects. In a test, they can be functions that record calls into an atom.
1(defn recording-boundary [calls]
2 {:now (constantly #inst "2026-05-18T12:00:00.000-00:00")
3 :save! #(swap! calls conj [:save %])
4 :notify! #(swap! calls conj [:notify %])})
This keeps the workflow testable without mocking global vars or relying on thread timing.
Isolation is most important around swap!, alter, and dosync.
| Code location | Allow | Avoid |
|---|---|---|
| Pure function | Data in, data out | I/O, random IDs, time reads |
| Atom update function | Derive next immutable value | Logging, HTTP, database writes |
| STM transaction | Read and update refs | Sending messages, file writes |
| Agent action | Update agent state and possibly effect by design | Unbounded blocking, hidden critical workflow |
| Boundary workflow | Explicit effects and failure handling | Retriable state updates with effects inside |
The boundary workflow is where you decide order, idempotency, retries, compensation, and observability.
1(defn register-user! [{:keys [save-user! publish-event!]} user]
2 (let [record (assoc user :status :active)
3 event {:type :user/registered
4 :user-id (:id user)}]
5 (save-user! record)
6 (publish-event! event)
7 record))
This function is effectful, but the order is visible. If both operations must be atomic, this function is not enough; use a database transaction or outbox. The code makes that review possible because the effects are not buried in a helper called from swap!.