Browse Clojure Foundations for Java Developers

Refactoring Imperative Java Code to Functional Clojure

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:

  • identify where mutation happens in the Java version
  • decide whether the Clojure replacement is a collection pipeline, a reducing step, or a state boundary
  • keep business rules pure first
  • introduce atom, I/O, or interop only where the program genuinely needs them

Refactoring Playbook

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

Exercise 1: Replace A Mutable Accumulator With reduce

Suppose 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:

  • the accumulator is still there, but it is explicit and returned
  • update says exactly which key changes
  • fnil handles the “missing key means zero” case without manual branching

Stretch task: change the function so it groups orders by status instead of just counting them.

Exercise 2: Replace “Build A List In A Loop” With A Collection Pipeline

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:

  • each step has one job
  • the pipeline reads like a data flow instead of a control-flow script
  • into [] makes the result type explicit when callers want a vector

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

Exercise 3: Separate File Reading From Business Rules

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/O
  • parse-order-line and large-order? are pure and easy to test
  • doall realizes the lazy sequence before the reader closes

Stretch task: change the pure portion so invalid rows return structured error data instead of throwing immediately.

Exercise 4: Turn A Mutable Object Into Data Plus Transition Functions

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:

  • the cart is just data
  • each rule is a function from old value to new value
  • there is no hidden mutation between calls

Stretch task: add a pure remove-item transition that drops the first matching line by SKU.

Exercise 5: Add An atom Only At The Runtime Boundary

After 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 atom
  • the atom only owns “what is the current cart right now?”
  • tests for business rules do not need shared mutable state

If multiple related identities must change together, stop and reconsider. An atom is for one independently changing identity, not for every stateful problem.

Before You Move On

If a refactoring from Java to Clojure still feels awkward, check whether you are making one of these mistakes:

  • preserving the Java class boundary when a plain map would do
  • introducing an atom before you have written pure transitions
  • translating loops into recursion when a collection pipeline is simpler
  • mixing file, console, or database access into functions that should only transform data

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

Knowledge Check

### Which Clojure form is the best first replacement for a Java loop that updates one summary map as it walks a collection? - [x] `reduce` - [ ] `atom` - [ ] `def` - [ ] `future` > **Explanation:** When the code is deriving one result from a collection, `reduce` keeps the accumulator explicit and returns a new value. An `atom` would add shared state where none is needed. ### In the `active-customer-emails` example, why is `into []` used at the end of the pipeline? - [x] To realize the pipeline into a vector for callers that want a concrete collection - [ ] To make `filter` eager before `map` - [ ] To allow mutation of the resulting emails - [ ] To convert the values into Java arrays > **Explanation:** `filter` and `map` produce sequences. `into []` is a clear way to say "return a vector here" when that is the desired output shape. ### What problem does `doall` solve in the file-reading example? - [x] It realizes the lazy sequence before `with-open` closes the reader - [ ] It converts strings into keywords - [ ] It retries failed I/O automatically - [ ] It ensures the file is read in parallel > **Explanation:** `line-seq` is lazy. Without realizing the sequence inside `with-open`, later consumers may try to read from a stream that has already been closed. ### Why is it usually better to write `add-item` and `apply-discount` as pure functions before wrapping a cart in an `atom`? - [x] The business rules become reusable and testable without shared state - [ ] Clojure does not allow `swap!` on maps - [ ] Pure functions are required before any interop - [ ] Atoms can only hold numbers > **Explanation:** Pure transition functions keep the domain logic independent of runtime state management. The `atom` then becomes a thin boundary around one current value instead of the place where all logic lives.
Revised on Friday, April 24, 2026