Work through a practical refactoring lab that turns mutable Java workflows into Clojure data transformations, pure functions, and explicit state boundaries.
This chapter has been building one big habit change: stop translating Java classes and loops mechanically, and start looking for data, transformations, and explicit boundaries.
That shift is easiest to internalize by refactoring real code shapes.
Use this page as a small lab, not as a syntax drill. For each exercise:
atom, I/O, or interop only where the program genuinely needs themThese are the first moves that usually work best when translating imperative Java into idiomatic Clojure.
| Java shape | First Clojure move | Why it is usually better |
|---|---|---|
| Loop updates an accumulator | reduce with an explicit initial value |
The changing result becomes a returned value, not hidden mutable state |
| Loop builds a derived list | ->> with filter, map, and into |
The transformation pipeline becomes easier to read and test |
| Method mixes I/O and business logic | Split into an effectful shell and pure functions | Rules can be tested without files, sockets, or consoles |
| Object owns mutable fields and rules | Represent state as data, then write transition functions | State changes become visible and composable |
| Program needs one current value over time | Wrap pure transitions in an atom |
Mutation is isolated to one explicit identity |
One important warning for Java developers: not every loop should become raw recursion. In Clojure, collection work often becomes map, filter, or reduce first. Reach for loop and recur when the algorithm truly needs explicit step-by-step state.
reduceSuppose you start with Java code that counts orders by status.
1public Map<String, Integer> countByStatus(List<Order> orders) {
2 Map<String, Integer> counts = new HashMap<>();
3 for (Order order : orders) {
4 String status = order.getStatus();
5 counts.put(status, counts.getOrDefault(status, 0) + 1);
6 }
7 return counts;
8}
The core job is not “run a loop.” The core job is “derive one summary value from many inputs.” That is a reduce problem.
1(defn count-by-status [orders]
2 (reduce (fn [counts {:order/keys [status]}]
3 (update counts status (fnil inc 0)))
4 {}
5 orders))
Try it with data like this:
1(count-by-status
2 [{:order/id "A-1" :order/status :paid}
3 {:order/id "A-2" :order/status :draft}
4 {:order/id "A-3" :order/status :paid}])
5;; => {:paid 2, :draft 1}
What to notice:
update says exactly which key changesfnil handles the “missing key means zero” case without manual branchingStretch task: change the function so it groups orders by status instead of just counting them.
Now take a common Java shape that filters and transforms data into a new list.
1public List<String> activeCustomerEmails(List<Customer> customers) {
2 List<String> emails = new ArrayList<>();
3 for (Customer customer : customers) {
4 if (customer.isActive() && customer.getEmail() != null) {
5 emails.add(customer.getEmail().toLowerCase());
6 }
7 }
8 return emails;
9}
In Clojure, this usually becomes a pipeline instead of a loop:
1(require '[clojure.string :as str])
2
3(defn active-customer-emails [customers]
4 (->> customers
5 (filter :customer/active?)
6 (map :customer/email)
7 (remove nil?)
8 (map str/lower-case)
9 (into [])))
Why this shape matters:
into [] makes the result type explicit when callers want a vectorCommon mistake: translating this kind of code into manual recursion just because Java used a loop. That usually adds complexity instead of removing it.
Stretch task: preserve the original order, but return only emails from customers in the "enterprise" segment.
Imperative Java methods often hide two jobs in one place: talking to the outside world and applying business rules.
1public List<String> loadLargeOrders(Path path) throws IOException {
2 List<String> results = new ArrayList<>();
3 try (BufferedReader reader = Files.newBufferedReader(path)) {
4 String line;
5 while ((line = reader.readLine()) != null) {
6 String[] parts = line.split(",");
7 BigDecimal amount = new BigDecimal(parts[1]);
8 if (amount.compareTo(new BigDecimal("1000")) >= 0) {
9 results.add(parts[0]);
10 }
11 }
12 }
13 return results;
14}
The Clojure version gets better when reading and parsing are not tangled together.
1(require '[clojure.java.io :as io]
2 '[clojure.string :as str])
3
4(defn parse-order-line [line]
5 (let [[id amount-text] (str/split line #",")]
6 {:order/id id
7 :order/amount (BigDecimal. amount-text)}))
8
9(defn large-order? [order minimum]
10 (>= (.compareTo (:order/amount order) minimum) 0))
11
12(defn read-lines [path]
13 (with-open [reader (io/reader path)]
14 (doall (line-seq reader))))
15
16(defn load-large-order-ids [path minimum]
17 (->> (read-lines path)
18 (map parse-order-line)
19 (filter #(large-order? % minimum))
20 (map :order/id)
21 (into [])))
The important design change is not the syntax. It is the boundary:
read-lines performs I/Oparse-order-line and large-order? are pure and easy to testdoall realizes the lazy sequence before the reader closesStretch task: change the pure portion so invalid rows return structured error data instead of throwing immediately.
A Java class often mixes state ownership, mutation, and domain rules.
1public class Cart {
2 private final List<BigDecimal> itemPrices = new ArrayList<>();
3 private BigDecimal discount = BigDecimal.ZERO;
4
5 public void addItem(BigDecimal price) {
6 itemPrices.add(price);
7 }
8
9 public void applyDiscount(BigDecimal discount) {
10 this.discount = discount;
11 }
12
13 public BigDecimal total() {
14 BigDecimal subtotal = BigDecimal.ZERO;
15 for (BigDecimal price : itemPrices) {
16 subtotal = subtotal.add(price);
17 }
18 return subtotal.subtract(subtotal.multiply(discount));
19 }
20}
In Clojure, start by making the cart plain data:
1(def empty-cart
2 {:cart/items []
3 :cart/discount 0M})
4
5(defn add-item [cart price]
6 (update cart :cart/items conj {:line/price price}))
7
8(defn apply-discount [cart percent]
9 (assoc cart :cart/discount percent))
10
11(defn subtotal [cart]
12 (reduce (fn [sum {:line/keys [price]}]
13 (+ sum price))
14 0M
15 (:cart/items cart)))
16
17(defn total [cart]
18 (let [subtotal-value (subtotal cart)
19 discount (:cart/discount cart)]
20 (- subtotal-value (* subtotal-value discount))))
Then use those functions as transitions:
1(-> empty-cart
2 (add-item 20M)
3 (add-item 30M)
4 (apply-discount 0.10M)
5 total)
6;; => 45.0M
This is the core migration move:
Stretch task: add a pure remove-item transition that drops the first matching line by SKU.
atom Only At The Runtime BoundaryAfter you have pure transition functions, you can decide whether the running program needs one changing identity.
That is when an atom becomes useful.
1(def current-cart
2 (atom empty-cart))
3
4(defn add-item! [price]
5 (swap! current-cart add-item price))
6
7(defn apply-discount! [percent]
8 (swap! current-cart apply-discount percent))
9
10(defn current-total []
11 (total @current-cart))
This is better than making mutation your starting point because the hard part is already solved:
add-item, apply-discount, and total still work without the atomIf multiple related identities must change together, stop and reconsider. An atom is for one independently changing identity, not for every stateful problem.
If a refactoring from Java to Clojure still feels awkward, check whether you are making one of these mistakes:
atom before you have written pure transitionsIf you can spot and avoid those mistakes, the next chapter on higher-order functions will feel much more natural. Many of the refactorings above already depend on thinking in terms of functions passed into map, filter, reduce, and swap!.