Browse Learn Clojure Foundations as a Java Developer

Adapt Java Design Pattern Intent to Clojure

Translate Java design patterns by preserving their design intent while replacing unnecessary class machinery with Clojure functions, data, namespaces, protocols, and explicit boundaries.

Java design patterns should not be migrated by recreating their class diagrams in Clojure. A pattern is a response to a design pressure: object creation, interchangeable behavior, notification, cross-cutting behavior, lifecycle control, or dependency isolation. Clojure often solves the same pressure with smaller tools.

The migration question is not “How do I implement the Factory pattern in Clojure?” It is “What uncertainty was the factory managing, and what is the simplest Clojure boundary that preserves that behavior?”

Start With Pattern Intent

Java pattern Intent in Java Common Clojure shape
Singleton One globally reachable instance Namespace value, explicit dependency, or managed state only when state is real.
Factory Choose a concrete object without exposing construction Function, constructor map, dispatch table, or data-driven builder.
Strategy Swap algorithms behind a common interface Pass a function or a map of functions.
Decorator Add behavior around an object Function composition, middleware, or data transformation pipeline.
Observer Notify dependents when something happens Event value, watch, pub/sub boundary, or explicit notification function.
Command Encapsulate a request for later execution Data command plus interpreter function, or thunk when no audit trail is needed.
Template Method Share algorithm while subclasses fill steps Function pipeline with named step functions.

This table does not mean the Java pattern was wrong. It means Clojure gives you a different vocabulary for the same design pressure.

Be Careful With Singleton

Java Singleton usually solves one of three problems: expensive construction, global access, or shared mutable state. These should not all become atoms.

Singleton reason Better Clojure response
Constant configuration Namespace value or configuration map passed into the system.
Expensive reusable object Lifecycle-managed component created by the application boundary.
Shared mutable state Atom only if process-local state is correct; durable truth belongs elsewhere.
Convenient global access Prefer explicit dependency passing so tests and rollout remain controlled.

If the old Singleton hid a database client, cache, or feature flag system, treat it as an operational boundary. Do not replace it with global state just because Clojure makes atoms easy.

Translate Strategy To Functions

The Strategy pattern becomes straightforward when algorithms are values.

1public interface DiscountPolicy {
2    BigDecimal discountFor(Customer customer, Cart cart);
3}

In Clojure, a discount policy can be a function.

1(defn enterprise-discount [{:customer/keys [tier]} {:cart/keys [subtotal]}]
2  (if (= tier :enterprise)
3    (* subtotal 0.15M)
4    0M))
5
6(defn apply-policy [policy customer cart]
7  (policy customer cart))

If the policy family travels together with names, descriptions, or thresholds, use data plus functions.

1(def discount-policies
2  {:enterprise {:label "Enterprise discount"
3                :discount enterprise-discount}
4   :none       {:label "No discount"
5                :discount (fn [_customer _cart] 0M)}})

This keeps the behavior testable without subclass setup.

Translate Factory To Data-Driven Construction

A Java Factory often hides construction choices. In Clojure, choose between plain constructors, a dispatch map, or data normalization.

 1(def builders
 2  {:email (fn [config] {:channel/type :email
 3                        :smtp/host (:smtp-host config)})
 4   :sms   (fn [config] {:channel/type :sms
 5                        :sms/provider (:provider config)})})
 6
 7(defn build-channel [{:keys [type] :as config}]
 8  (if-let [builder (get builders type)]
 9    (builder config)
10    (throw (ex-info "Unknown channel type" {:type type}))))

This is factory behavior, but the variation is visible as data. Adding a new case is a map entry plus tests, not a subclass hierarchy.

Translate Decorator To Composition

Decorator usually adds behavior around an existing operation. In Clojure, that often becomes middleware or a pipeline.

 1(defn with-audit [handler]
 2  (fn [request]
 3    (let [response (handler request)]
 4      (assoc response :audit/request-id (:request/id request)))))
 5
 6(defn with-timing [handler]
 7  (fn [request]
 8    (let [start (System/nanoTime)
 9          response (handler request)]
10      (assoc response :timing/nanos (- (System/nanoTime) start)))))
11
12(def handler
13  (-> base-handler
14      with-audit
15      with-timing))

This preserves the “wrap behavior” intent without building wrapper classes. Use this shape when each wrapper has a clear responsibility and tests can verify ordering.

Choose The Smallest Clojure Tool

If the pattern exists because… Start with…
One operation varies Pass a function.
Several operations vary together Pass a dependency map.
Variation is selected by domain data Use a dispatch table or case.
Dispatch must stay open across namespaces Consider a multimethod.
Java callers require a typed contract Keep or implement the Java interface at the boundary.
Framework lifecycle owns the pattern Keep the framework shape and delegate pure behavior to Clojure.

This is the main discipline: do not use protocols, multimethods, records, atoms, or macros because a pattern name feels familiar. Use them when their specific trade-off fits the migration boundary.

Practice

  1. Pick one Java design pattern in your codebase and write the design pressure it solves.
  2. Decide whether the Clojure version needs a function, data map, protocol, multimethod, atom, adapter, or no special pattern at all.
  3. Write one equivalence test that proves the old pattern behavior and the Clojure replacement agree.
  4. Identify one pattern that should remain at the Java boundary for now.

Key Takeaways

  • Preserve pattern intent, not class diagrams.
  • Many Java patterns become functions, data, dependency maps, or pipelines.
  • Singleton should not automatically become an atom.
  • Keep framework-required patterns at the boundary until pure behavior is isolated.
  • Choose the smallest Clojure tool that makes the behavior testable and reviewable.

Quiz: Adapting Java Patterns

### What should be preserved when migrating a Java design pattern to Clojure? - [x] The design intent and behavior. - [ ] The original class diagram. - [ ] The exact package structure. - [ ] The number of interfaces. > **Explanation:** Pattern names are secondary; the important part is the design pressure and behavior. ### What is often the best Clojure shape for a simple Strategy pattern? - [x] A function passed as a value. - [ ] A global atom. - [ ] A macro for each strategy. - [ ] A recreated abstract class. > **Explanation:** Strategies are interchangeable algorithms, and functions represent that directly. ### Why should Singleton not automatically become an atom? - [x] The original need may be constant configuration, lifecycle management, or convenient access rather than real shared state. - [ ] Clojure does not support atoms. - [ ] Atoms cannot be tested. - [ ] Java callers require atoms. > **Explanation:** Choose the state model based on the actual operational need. ### What commonly replaces a Factory pattern in Clojure? - [x] A constructor function, dispatch map, or data-driven builder. - [ ] A synchronized static method. - [ ] A subclass hierarchy. - [ ] A private constructor. > **Explanation:** Clojure can make construction choices explicit with functions and data. ### When should a Java framework pattern stay at the boundary? - [x] When framework lifecycle, callbacks, or transactions depend on it. - [ ] Whenever a pattern has a name. - [ ] Only when the code has no tests. - [ ] Never, because Clojure cannot call Java. > **Explanation:** Keeping framework lifecycle stable reduces migration risk while pure behavior moves behind it.
Revised on Saturday, May 23, 2026