Study concrete Java-to-Clojure pattern refactors for pricing strategies, notification observers, and command handlers with tests, boundaries, and rollout concerns.
Pattern refactoring is easiest to review when it is tied to a real migration slice. This page walks through three case studies that Java teams often encounter: pricing strategies, notification observers, and command handlers.
Each case keeps the same discipline: preserve behavior, expose data shape, isolate effects, and choose the smallest Clojure tool that fits.
A Java pricing subsystem may use an interface and multiple strategy classes.
1public interface PricingPolicy {
2 Money price(Customer customer, Cart cart);
3}
In Clojure, the first migration slice can keep the Java boundary and move the pricing decision behind it.
1(defn standard-price [_customer {:cart/keys [subtotal]}]
2 subtotal)
3
4(defn enterprise-price [{:customer/keys [tier]} {:cart/keys [subtotal]}]
5 (if (= tier :enterprise)
6 (* subtotal 0.90M)
7 subtotal))
8
9(def pricing-policies
10 {:standard standard-price
11 :enterprise enterprise-price})
12
13(defn price-cart [policy-id customer cart]
14 (let [policy (get pricing-policies policy-id standard-price)]
15 (policy customer cart)))
| Migration concern | Review question |
|---|---|
| Behavior equivalence | Do Java and Clojure policies return the same money values for fixture carts? |
| Numeric rules | Are rounding and scale handled at the same boundary as before? |
| Java caller stability | Does the Java adapter still return the expected Money type? |
| Extensibility | Can a new pricing policy be added as a function plus tests? |
Do not remove the Java interface until callers and rollout no longer need it. The Clojure policy map can live behind the existing contract while the team validates behavior.
Java Observer often mixes registration, notification, and side effects.
1class OrderSubject {
2 private final List<OrderObserver> observers = new ArrayList<>();
3
4 void notifySubmitted(Order order) {
5 for (OrderObserver observer : observers) {
6 observer.orderSubmitted(order);
7 }
8 }
9}
In a Clojure migration, first make the event explicit.
1(defn submitted-event [order]
2 {:event/type :order.submitted
3 :order/id (:order/id order)
4 :customer/id (:customer/id order)})
5
6(defn notification-commands [event]
7 (case (:event/type event)
8 :order.submitted
9 [{:command/type :send-email
10 :template :order-submitted
11 :customer/id (:customer/id event)}
12 {:command/type :publish-metric
13 :metric/name :orders.submitted}]
14
15 []))
The event and commands are pure values. The effectful shell can interpret them.
1(defn run-notification-command! [{:keys [send-email! publish-metric!]} command]
2 (case (:command/type command)
3 :send-email (send-email! command)
4 :publish-metric (publish-metric! (:metric/name command))))
This refactor makes notification behavior testable without sending email or touching a metrics backend.
Java Command often packages an operation as an object with execute.
1public interface Command {
2 void execute();
3}
In Clojure, represent the command as data when auditability, retries, queues, or dry runs matter.
1(defn cancel-policy-command [policy-id reason]
2 {:command/type :cancel-policy
3 :policy/id policy-id
4 :cancel/reason reason})
5
6(defn interpret-command! [{:keys [load-policy save-policy! now]} command]
7 (case (:command/type command)
8 :cancel-policy
9 (let [policy (load-policy (:policy/id command))
10 cancelled (assoc policy
11 :policy/status :cancelled
12 :policy/cancelled-at (now)
13 :cancel/reason (:cancel/reason command))]
14 (save-policy! cancelled)
15 cancelled)))
If the command is only an immediate callback with no audit or queue needs, a function may be enough. Use data when the operation needs to be inspected, stored, retried, authorized, or compared before execution.
| Migration case | Refactor and review focus |
|---|---|
| Pricing strategy | Keep the Java boundary stable, move the variation into a function map, and compare money-value fixtures. |
| Notification observer | Make the event value explicit, interpret planned commands at the boundary, and avoid duplicate external effects during shadow runs. |
| Command handler | Represent auditable work as data, run it through an effectful interpreter, and keep adapter tests plus a rollback path. |
These case studies are not prescriptions for every system. They show how to preserve intent while reducing class scaffolding and making behavior observable.
!.