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?”
| 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.
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.
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.
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.
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.
| 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.