Browse Learn Clojure Foundations as a Java Developer

Isolate Side Effects at Clojure Boundaries

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.

Separate Shape From Execution

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.

Namespace Boundaries Help

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.

Inject Effectful Functions

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.

Keep Retriable Code Effect-Free

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.

A Small Boundary Workflow

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!.

Knowledge Check

### Why return a plan from pure logic before executing effects? - [x] The decision can be tested and reused without performing I/O - [ ] Clojure maps cannot be sent to boundary functions - [ ] Plans automatically persist to a database - [ ] It prevents all runtime errors > **Explanation:** A plan is ordinary data. Tests can verify the decision without sending email, writing files, or coordinating threads. ### What belongs in a boundary namespace such as `myapp.orders.io`? - [x] Database, HTTP, filesystem, or message broker calls - [ ] Pure pricing calculations - [ ] Only macros - [ ] All atom update functions > **Explanation:** Boundary namespaces make side effects visible. Pure transformations should remain in namespaces that are easy to test without I/O. ### Why inject functions like `save!` and `notify!`? - [x] It makes effects explicit and easy to replace in tests - [ ] It forces every call to be asynchronous - [ ] It makes `swap!` run faster - [ ] It disables Java interop > **Explanation:** Passing effectful functions as dependencies avoids hidden globals and lets tests use recording or fake boundaries.
Revised on Saturday, May 23, 2026