Refactor Java classes by separating data shape, behavior, dependencies, and side effects into Clojure maps, constructors, pure functions, and explicit boundary adapters.
Decomposing a Java class into Clojure is not a mechanical conversion from fields to map keys and methods to functions. A good refactor asks what roles the class is playing: data carrier, behavior owner, dependency holder, lifecycle participant, validation gate, or side-effect coordinator.
Java often combines several of those roles in one class. Clojure works best when they are separated: immutable data represents facts, pure functions transform values, and effectful adapters own I/O or framework integration.
Before writing Clojure, classify the class responsibilities.
| Java class responsibility | Clojure refactoring target |
|---|---|
| Fields and getters | Map, record, or validated constructor at the boundary. |
| Business method | Pure function that receives required data explicitly. |
| Injected repository or client | Function dependency, dependency map, or Java adapter. |
| Setter or staged mutation | Function that returns updated immutable data. |
| Lifecycle callback | Keep at Java/framework boundary until the behavior is isolated. |
| Validation logic | Constructor validation, spec/schema check, or explicit predicate. |
This classification prevents a common migration mistake: turning a class into one giant Clojure namespace that still hides the same coupling.
Consider a Java class that mixes data, rules, and dependency calls.
1public final class RenewalQuote {
2 private final Customer customer;
3 private final Policy policy;
4 private final RatingClient ratingClient;
5
6 public Quote calculate() {
7 BigDecimal base = ratingClient.rate(policy.productCode(), customer.region());
8 BigDecimal loyalty = customer.yearsActive() >= 5
9 ? new BigDecimal("0.90")
10 : BigDecimal.ONE;
11 return new Quote(policy.id(), base.multiply(loyalty));
12 }
13}
The data shape can move before the external rating call moves.
1(defn renewal-input [customer policy]
2 {:customer/id (:customer/id customer)
3 :customer/region (:customer/region customer)
4 :customer/years-active (:customer/years-active customer)
5 :policy/id (:policy/id policy)
6 :policy/product-code (:policy/product-code policy)})
The pure rule can then operate on values.
1(defn loyalty-factor [{:customer/keys [years-active]}]
2 (if (>= years-active 5) 0.90M 1M))
3
4(defn quote-from-base [renewal base-rate]
5 {:quote/policy-id (:policy/id renewal)
6 :quote/amount (* base-rate (loyalty-factor renewal))})
The rating client remains an effectful boundary until the team intentionally migrates it.
Java constructors often protect invariants through types, required fields, and exceptions. Clojure maps are flexible, so you need an explicit place to validate important assumptions.
1(defn make-renewal-input [{:customer/keys [id region years-active]
2 :policy/keys [id product-code]}]
3 (when-not region
4 (throw (ex-info "Customer region is required" {:field :customer/region})))
5 (when-not product-code
6 (throw (ex-info "Policy product code is required" {:field :policy/product-code})))
7 {:customer/id id
8 :customer/region region
9 :customer/years-active (or years-active 0)
10 :policy/id id
11 :policy/product-code product-code})
For migration, validation belongs at the Java-Clojure boundary or at data constructors. Inside the pure core, prefer ordinary maps and functions that are easy to test.
When a Java method mutates internal state, translate the business transition rather than the setter.
1public void markRenewed(Instant renewedAt) {
2 this.status = Status.RENEWED;
3 this.renewedAt = renewedAt;
4}
In Clojure, make the state transition explicit.
1(defn mark-renewed [policy renewed-at]
2 (assoc policy
3 :policy/status :renewed
4 :policy/renewed-at renewed-at))
This function is easy to test because the old value and new value are both visible. There is no hidden object identity that can surprise another caller.
| If the Java class is… | First Clojure move |
|---|---|
| A domain object with simple rules | Extract data map and pure rule functions. |
| A service with many dependencies | Keep the Java service, move one pure decision behind it. |
| A framework entity | Add adapter or mapper code first; delay lifecycle migration. |
| A mutable coordinator | Identify which state is real shared state and which is just local accumulation. |
| A polymorphic base class | Decide whether functions, protocols, multimethods, or data dispatch fit best. |
The first move should make behavior easier to test. If the refactor makes the system harder to observe or roll back, the slice is too large.