Browse Clojure Foundations for Java Developers

Isolating Side Effects

Use a functional core and an imperative shell so effectful code stays thin and business rules stay easy to test.

The most practical rule in this chapter is simple:

Keep your functional core pure, and keep your imperative shell responsible for effects.

That is much easier to carry into production than vague advice like “avoid side effects.” Real systems need I/O, databases, clocks, and Java libraries. The win comes from keeping those concerns at the edges instead of mixing them into every rule.

The Boundary You Want

The diagram below shows the shape Clojure code is usually aiming for.

Functional core and imperative shell boundary

What to notice:

  • the shell reads and writes the world
  • the core decides what the next value or action should be
  • the core returns data that the shell can persist, publish, or log

This is still recognizably similar to a good Java service layer. The difference is that Clojure pushes harder to keep the decision-making code independent from the effectful orchestration.

A Small Example

Start with a pure function that computes the next order state and the events to emit:

1(defn prepare-submission [order]
2  (let [total  (reduce + 0M (map :line/total (:order/lines order)))
3        priced (assoc order
4                      :order/total total
5                      :order/status :submitted)]
6    {:order  priced
7     :events [{:event/type :order/submitted
8               :order/id   (:order/id order)
9               :order/total total}]}))

Nothing here loads from a database or sends a message. It just returns a description of what should happen next.

Now the effectful shell can orchestrate the outside world:

1(defn submit-order! [load-order save-order! publish! order-id]
2  (let [existing-order       (load-order order-id)
3        {:keys [order events]} (prepare-submission existing-order)]
4    (save-order! order)
5    (run! publish! events)
6    order))

That split gives you two different kinds of tests:

  • plain data-in, data-out tests for prepare-submission
  • boundary tests for submit-order!

This is usually a better trade than one large function that does everything.

Why Returning Data Helps

Java developers often move straight from “method does everything” to “function does everything.” That preserves the old coupling.

Returning data instead of performing effects immediately gives you better options:

  • inspect the result in the REPL
  • test business rules with plain maps
  • decide later whether to save, publish, retry, or batch the work
  • reuse the same pure core from a web handler, job runner, or REPL script

It is often useful to think in two questions:

  1. What should happen?
  2. Who should perform it?

The pure core answers the first question. The effectful shell answers the second.

Keep The Shell Thin

An imperative shell is not an excuse to dump all complexity into a wrapper function.

A good shell mostly:

  • loads the required inputs
  • calls pure functions
  • commits state changes
  • publishes or logs external outcomes

If the shell grows large conditionals and embedded business rules, that is usually a sign the core is not doing enough yet.

A Common Retry Mistake

Once you start using atoms or refs, retry behavior matters. If an update function runs more than once under contention, hidden side effects become bugs.

For example, this is risky:

1(swap! state
2       (fn [current]
3         (println "updating!")   ;; bad place for a side effect
4         (assoc current :status :done)))

The update itself is fine. The println is not, because swap! may retry the function. Keep effectful work outside retryable update logic.

What To Refactor First In Mixed Code

If you inherit a large effectful function, do not try to make the whole system pure in one pass. Start here:

  1. extract the calculation that decides the next value
  2. make that calculation a pure function over plain data
  3. leave the I/O in the calling function
  4. repeat until the shell is mostly orchestration

That incremental refactor works well for Java teams because it improves testability without forcing a full rewrite.

Knowledge Check

### What is the job of the imperative shell in the functional-core pattern? - [x] Perform I/O and orchestration around the pure decision logic - [ ] Replace all pure functions with atoms - [ ] Keep business rules hidden from callers - [ ] Make every function asynchronous > **Explanation:** The shell owns interaction with the outside world. The pure core should stay focused on computing the next value or action. ### Why is `prepare-submission` easier to reuse than `submit-order!`? - [x] It returns data and does not depend on a database or message publisher - [ ] It uses maps instead of vectors - [ ] It always runs faster - [ ] It can only be called once > **Explanation:** Pure functions are easier to reuse because they do not require runtime collaborators to do useful work. ### Why is `println` inside a `swap!` update function a bad idea? - [x] The update function may be retried, so the side effect can happen more than once - [ ] `println` is forbidden in Clojure - [ ] Atoms can only store numbers - [ ] `swap!` automatically suppresses output > **Explanation:** Retryable state updates should stay pure. Observable side effects belong outside the retryable function body. ### What is usually the first good refactor for a large mixed function? - [x] Extract the pure calculation that decides the next value or action - [ ] Convert every local binding into a global var - [ ] Move all code into one namespace - [ ] Replace the whole function with a macro > **Explanation:** Pulling out the pure rule is the easiest way to improve testability without rewriting the entire boundary at once.
Revised on Friday, April 24, 2026