Browse Learn Clojure Foundations as a Java Developer

Replace Java Inheritance with Clojure Composition

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.

Identify What The Hierarchy Provides

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.

Replace Template Methods With Pipelines

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.

Use Data Dispatch For Simple Variation

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.

Use Protocols When Type Polymorphism Is Real

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.

Use Multimethods For Open Dispatch

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.

Keep Framework Inheritance At The Edge

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.

Review Composition Choices

Ask these questions before accepting the refactor:

  • Is a plain function enough?
  • Is variation better represented as data?
  • Does this need open dispatch, type dispatch, or no dispatch at all?
  • Are Java/framework requirements isolated at the boundary?
  • Can tests add a new behavior without subclass setup?
  • Did the refactor remove coupling or just rename it?

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.

Practice

  1. Pick one Java base class and list what it provides: shared helpers, template method, dispatch, fields, or framework integration.
  2. Replace one template method with a Clojure function that accepts named step functions.
  3. Implement one variation as a dispatch table.
  4. Decide whether any remaining variation truly needs a protocol or multimethod.

Key Takeaways

  • Composition separates reuse, dispatch, data, and lifecycle concerns.
  • Plain functions and dependency maps often replace abstract helper classes.
  • Data dispatch is a good first choice for simple closed variation.
  • Protocols and multimethods are useful when their dispatch model is justified.
  • Keep framework inheritance at the edge until the behavior behind it is isolated and tested.

Quiz: Replacing Inheritance With Composition

### What should you identify before replacing a Java hierarchy? - [x] The role the hierarchy plays, such as reuse, dispatch, fields, lifecycle, or framework integration. - [ ] Only the number of subclasses. - [ ] The age of the base class. - [ ] The package naming convention. > **Explanation:** Different hierarchy roles need different Clojure replacements. ### What is a good replacement for a template method pattern? - [x] A function pipeline that receives variable steps explicitly. - [ ] A global mutable singleton. - [ ] A macro for every subclass. - [ ] A record for every method. > **Explanation:** Passing steps as functions preserves the shared algorithm while making variation explicit. ### When is data dispatch often enough? - [x] When behavior varies by a simple domain value such as `:kind` or `:type`. - [ ] When a framework requires subclassing. - [ ] When Java callers require bytecode inheritance. - [ ] When dispatch must depend on many open criteria. > **Explanation:** A dispatch table is simple and reviewable for closed data-driven variation. ### When should framework inheritance stay at the edge? - [x] When the framework owns lifecycle, callbacks, or transaction behavior. - [ ] Always, even for business rules. - [ ] Never, because Clojure cannot call Java. - [ ] Only when tests are absent. > **Explanation:** Keeping framework lifecycle stable reduces migration risk while pure behavior moves behind it. ### What is a warning sign in a Clojure inheritance refactor? - [x] Every Java class and interface becomes a matching record and protocol. - [ ] Shared helpers become plain functions. - [ ] Variation is represented as data where appropriate. - [ ] Framework adapters stay at the boundary. > **Explanation:** One-for-one structural translation often preserves the old coupling instead of improving the design.
Revised on Saturday, May 23, 2026