Browse Learn Clojure Foundations as a Java Developer

Refactor Java Patterns into Clojure Case Studies

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.

Case Study: Pricing Strategy

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.

Case Study: Observer To Event Boundary

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.

Case Study: Command Handler

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.

Compare Before And After

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.

Rollout Checklist

  • Keep the Java boundary stable for the first release.
  • Compare old and new behavior with fixtures before production traffic.
  • Do not duplicate writes, emails, queue messages, or metrics during shadow tests.
  • Mark effectful Clojure functions with !.
  • Remove old Java pattern code only after production evidence and review ownership are stable.

Practice

  1. Pick one pattern-heavy Java class and identify the pattern intent.
  2. Write a Clojure function, data command, or dependency map that preserves that intent.
  3. Define fixture comparisons between old Java behavior and new Clojure behavior.
  4. Decide what should remain at the Java boundary for the first release.

Key Takeaways

  • Case studies should focus on behavior, boundaries, tests, and rollout, not only syntax.
  • Strategy often becomes a function map behind a stable Java contract.
  • Observer often becomes explicit event data plus an effectful interpreter.
  • Command often becomes data when audit, queueing, retry, or dry-run behavior matters.
  • Shadow comparisons must avoid duplicate external effects.

Quiz: Pattern Refactoring Case Studies

### What is the safest first move when migrating a Java Strategy hierarchy? - [x] Keep the Java boundary stable and move the strategy decision behind it. - [ ] Delete the interface before tests exist. - [ ] Create one atom per strategy. - [ ] Replace every strategy with a macro. > **Explanation:** A stable boundary lets Clojure behavior be validated without disrupting callers. ### Why convert Observer behavior into event data? - [x] Events and planned commands can be tested without sending external notifications. - [ ] Events remove the need for all side effects. - [ ] Java cannot use observers. - [ ] Clojure maps send email automatically. > **Explanation:** Event values separate the decision from effectful notification delivery. ### When is a command better represented as data instead of only a function? - [x] When it needs audit, queueing, retry, dry-run, authorization, or inspection. - [ ] When it has no observable behavior. - [ ] When tests are not needed. - [ ] When Java callers require a subclass. > **Explanation:** Data commands are useful when execution needs to be controlled or observed. ### What should be avoided during shadow comparisons? - [x] Duplicate writes, emails, queue messages, or metrics. - [ ] Comparing pure values. - [ ] Running fixture tests. - [ ] Inspecting command data. > **Explanation:** Shadow runs must not produce duplicate external effects. ### When should old Java pattern code be removed? - [x] After production evidence, tests, and ownership are stable. - [ ] As soon as Clojure code compiles. - [ ] Before Java callers are tested. - [ ] Before rollback is defined. > **Explanation:** Cleanup should follow evidence and support readiness.
Revised on Saturday, May 23, 2026