Refactor Java inheritance hierarchies into Clojure functions, data-driven dispatch, protocols, multimethods, and dependency maps without recreating unnecessary class structure.
Java inheritance often solves several problems at once: code reuse, polymorphism, shared fields, template methods, and framework integration. Clojure usually separates those problems. Reuse comes from small functions, polymorphism comes from protocols, multimethods, or data dispatch, and framework integration stays at the boundary when needed.
Replacing inheritance with composition is not about refusing polymorphism. It is about choosing the smallest mechanism that fits the behavior instead of recreating a class hierarchy in a new language.
Before refactoring, ask why the hierarchy exists.
| Java hierarchy role | Clojure replacement to consider |
|---|---|
| Shared helper methods | Plain functions in a namespace. |
| Template method algorithm | Function pipeline with injected steps. |
| Subclass-specific behavior | Function map, protocol, multimethod, or case on data. |
| Shared fields | Explicit data map keys or constructor defaults. |
| Framework-required base class | Keep the Java/framework boundary and compose behind it. |
| Type-driven dispatch for Java callers | Protocol or Java interface adapter. |
Do not start with defrecord and protocols just because Java had abstract classes. Start with the behavior.
A Java base class might define an algorithm and let subclasses override steps.
1abstract class ImportJob {
2 public ImportResult run(Path file) {
3 List<Row> rows = read(file);
4 List<Row> valid = validate(rows);
5 return persist(valid);
6 }
7
8 abstract List<Row> validate(List<Row> rows);
9 abstract ImportResult persist(List<Row> rows);
10}
In Clojure, pass the variable steps explicitly.
1(defn run-import [{:keys [read validate persist]} file]
2 (-> file
3 read
4 validate
5 persist))
The algorithm is still shared, but the variation is visible as data: a map of functions. Tests can provide fake steps without building subclasses.
If behavior varies by a small data value, a dispatch table can be clearer than a protocol.
1(def discount-rules
2 {:percent (fn [{:keys [rate]} subtotal]
3 (* subtotal rate))
4 :fixed (fn [{:keys [amount]} _subtotal]
5 amount)
6 :none (fn [_rule _subtotal]
7 0M)})
8
9(defn discount-amount [{:keys [kind] :as rule} subtotal]
10 ((get discount-rules kind (:none discount-rules)) rule subtotal))
This is simple, inspectable, and easy to extend when the dispatch key is already part of the domain data.
Protocols are useful when different concrete types need to support the same operation, especially near Java interop or performance-sensitive boundaries.
1(defprotocol ChargeSource
2 (charge-amount [source]))
3
4(defrecord InvoiceCharge [amount]
5 ChargeSource
6 (charge-amount [_] amount))
7
8(defrecord UsageCharge [units rate]
9 ChargeSource
10 (charge-amount [_] (* units rate)))
Use this when the type distinction matters. If the distinction is already a map key such as :charge/type, data dispatch may be simpler.
Multimethods help when dispatch depends on more than one value or when the set of cases should remain open across namespaces.
1(defmulti route-event
2 (fn [event context]
3 [(:event/type event) (:tenant/tier context)]))
4
5(defmethod route-event [:invoice/overdue :enterprise] [event context]
6 {:route :account-manager
7 :event event
8 :tenant (:tenant/id context)})
9
10(defmethod route-event :default [event _context]
11 {:route :standard-queue
12 :event event})
Multimethods are powerful, so use them deliberately. For a closed set of simple cases, a plain case or dispatch table is easier to review.
Sometimes inheritance is not your design choice. A framework may require extending a base class or implementing a Java interface. Do not fight that boundary during the first migration.
| Situation | Recommended move |
|---|---|
| Framework requires subclassing | Keep the Java subclass and delegate pure behavior to Clojure. |
| Java callers require an interface | Keep the interface and adapt to Clojure functions. |
| Base class owns lifecycle and transactions | Leave lifecycle in Java until the transaction boundary is explicit. |
| Hierarchy only exists for business variation | Replace with functions, dispatch data, protocols, or multimethods. |
The goal is not ideological purity. The goal is lower coupling and clearer behavior without breaking a stable integration point.
Ask these questions before accepting the refactor:
If the Clojure code has a record and protocol for every former class and interface, pause. That may still be object-oriented design translated too literally.