Refactor a mutable Java order model into Clojure value transformations, and see where state, rules, and side effects land afterward.
The fastest way to feel the Java-to-Clojure shift is to refactor a class that mixes mutable state and business rules into data plus pure transformations.
This case study does exactly that.
Suppose you inherit a Java Order class like this:
1public class Order {
2 private final String id;
3 private final List<LineItem> items = new ArrayList<>();
4 private BigDecimal discount = BigDecimal.ZERO;
5 private Status status = Status.DRAFT;
6
7 public Order(String id) {
8 this.id = id;
9 }
10
11 public void addItem(LineItem item) {
12 items.add(item);
13 }
14
15 public void applyDiscount(BigDecimal amount) {
16 discount = amount;
17 }
18
19 public void submit() {
20 if (items.isEmpty()) {
21 throw new IllegalStateException("Cannot submit an empty order");
22 }
23 status = Status.SUBMITTED;
24 }
25}
This is not bad Java. It is a very normal object-oriented shape:
But the model hides several things together:
That makes reuse and testing heavier than they need to be.
The first Clojure move is to model the order as a plain value:
1(defn new-order [id]
2 {:order/id id
3 :status :draft
4 :discount 0M
5 :items []})
This already changes the design conversation.
You no longer ask:
You ask:
That shift is one of Clojure’s biggest simplifications.
Each Java mutator becomes a function from old value to new value:
1(defn add-item [order item]
2 (update order :items conj item))
3
4(defn apply-discount [order amount]
5 (assoc order :discount amount))
6
7(defn submit-order [order]
8 (if (seq (:items order))
9 (assoc order :status :submitted)
10 (throw (ex-info "Cannot submit an empty order"
11 {:order/id (:order/id order)}))))
These functions do not mutate anything. They just return the next value.
That gives you a much cleaner testing story:
The workflow that used to live across object mutation now becomes explicit value transformation:
1(-> (new-order "A-1001")
2 (add-item {:sku "A-1" :qty 2 :unit-price 15M})
3 (add-item {:sku "B-2" :qty 1 :unit-price 40M})
4 (apply-discount 5M)
5 submit-order)
This is easier to read because each step takes a value and returns a value.
Earlier versions can still be kept for debugging or comparison if you need them. No defensive copy policy is required.
Real applications still have current state somewhere.
If this order is evolving in memory, you can keep the identity in an atom while preserving pure business rules:
1(def current-order (atom (new-order "A-1001")))
2
3(swap! current-order add-item {:sku "A-1" :qty 2 :unit-price 15M})
4(swap! current-order apply-discount 5M)
5(swap! current-order submit-order)
The important split is:
add-item, apply-discount, and submit-order are still pureThis is the “functional core, imperative shell” idea showing up again in a smaller form.
| Concern | Mutable Java object | Clojure value model |
|---|---|---|
| Business rules | Interleaved with mutation | Pure transitions over data |
| Testing | Often object-lifecycle oriented | Plain input/output assertions |
| Debugging | Need to inspect who changed the object | Compare before and after values directly |
| Reuse | Methods tied to object ownership | Functions reusable anywhere the data shape fits |
This is why many Java engineers describe Clojure as “less tangled” even before the system gets large.
The refactor does not eliminate:
It just moves them into clearer places.
That is the real win. Clojure is not removing complexity from the domain. It is removing accidental complexity from the implementation shape.