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 diagram below shows the shape Clojure code is usually aiming for.
What to notice:
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.
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:
prepare-submissionsubmit-order!This is usually a better trade than one large function that does everything.
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:
It is often useful to think in two questions:
The pure core answers the first question. The effectful shell answers the second.
An imperative shell is not an excuse to dump all complexity into a wrapper function.
A good shell mostly:
If the shell grows large conditionals and embedded business rules, that is usually a sign the core is not doing enough yet.
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.
If you inherit a large effectful function, do not try to make the whole system pure in one pass. Start here:
That incremental refactor works well for Java teams because it improves testability without forcing a full rewrite.