Browse Learn Clojure Foundations as a Java Developer

Decompose Java Classes into Clojure Data and Functions

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.

Identify The Roles Inside The Class

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.

Extract Data Shape First

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.

Keep Constructors Honest

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.

Replace Methods With Named Transformations

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.

Do Not Decompose Everything At Once

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.

Practice

  1. Choose one Java class and list its data, methods, dependencies, validation, and side effects.
  2. Create a Clojure data shape for the values needed by one business method.
  3. Extract one pure function from that method and test it with plain maps.
  4. Decide which class responsibilities should stay at the Java boundary for now.

Key Takeaways

  • Decompose responsibilities, not just syntax.
  • Extract data shape and pure behavior before moving framework lifecycle code.
  • Validate important map assumptions at constructors or boundaries.
  • Mutable methods usually become named state-transition functions that return new data.
  • A good class decomposition makes behavior easier to test and the migration easier to roll back.

Quiz: Decomposing Java Classes

### What should you identify before converting a Java class to Clojure? - [x] The roles the class plays, such as data, behavior, dependencies, validation, and side effects. - [ ] Only the number of fields. - [ ] The line count of each method. - [ ] The package name. > **Explanation:** Role classification prevents the same coupling from being recreated in Clojure. ### Where should important map validation usually happen? - [x] At constructors or Java-Clojure boundaries. - [ ] Randomly inside every pure function. - [ ] Only in production logs. - [ ] Never, because maps are always valid. > **Explanation:** Boundaries and constructors are the right place to enforce expected data shape. ### What is a good first move for a Java service with many dependencies? - [x] Keep the service boundary and move one pure decision behind it. - [ ] Rewrite every dependency at once. - [ ] Replace all dependencies with global atoms. - [ ] Delete the Java tests. > **Explanation:** Moving one pure decision keeps risk bounded and behavior testable. ### What does a mutable Java method often become in Clojure? - [x] A function that returns updated immutable data. - [ ] A setter on a Clojure map. - [ ] A hidden static field. - [ ] A macro with side effects. > **Explanation:** Clojure state transitions are usually explicit value transformations. ### Why avoid decomposing everything at once? - [x] Too many simultaneous changes make behavior, rollback, and review harder. - [ ] Clojure cannot represent large domains. - [ ] Java and Clojure cannot interoperate. - [ ] Pure functions require framework lifecycle code. > **Explanation:** Small slices keep migration risk and review scope manageable.
Revised on Saturday, May 23, 2026