Move a Java application toward Clojure by choosing one stable seam, extracting pure behavior, adding equivalence tests, routing through an adapter, and rolling out with controlled production evidence.
A Java-to-Clojure migration should look less like a rewrite and more like a sequence of controlled slices. Each slice keeps production callers stable, moves one behavior into Clojure, proves equivalence, and gives the team a rollback path.
This page continues the application profile from the previous case study page. The first slice is an account summary calculation: Java still owns HTTP, repositories, transactions, and notifications; Clojure owns the pure decision over account and order values.
Use the same loop for each slice.
| Step | Migration checkpoint |
|---|---|
| Baseline | Capture current Java behavior with fixtures and production examples. Move on only when golden inputs and expected outputs are reviewed. |
| Extract seam | Add a Java boundary that can call either old Java logic or new Clojure logic. Move on only when the caller contract stays stable. |
| Implement pure core | Move the decision into a Clojure namespace with plain data in and data out. Move on only when unit tests pass without databases or services. |
| Add adapter | Convert Java domain objects to Clojure maps and convert results back. Move on only when adapter tests cover nulls, numbers, dates, and empty collections. |
| Shadow compare | Run old and new behavior side by side without duplicate effects. Move on only when differences are logged and triaged. |
| Cut over | Route production traffic to the Clojure path behind a feature flag. Move on only when rollback and ownership are documented. |
The loop is intentionally repetitive. Repetition is what makes migration safe.
Before writing Clojure, capture the current output for representative inputs. A fixture is more useful than a vague test name.
1@Test
2void summarizesPastDueAccount() {
3 Account account = account("A-100", money("1000.00"));
4 List<Order> orders = List.of(
5 order("O-1", money("250.00"), true),
6 order("O-2", money("900.00"), false)
7 );
8
9 AccountSummary summary = service.summarize(account, orders);
10
11 assertEquals(money("1150.00"), summary.openBalance());
12 assertTrue(summary.overLimit());
13 assertEquals(List.of("Past due order: O-1"), summary.warnings());
14}
Do not start by testing Clojure. Start by making the Java behavior explicit enough to preserve or intentionally change.
The Clojure core should be boring: values in, values out.
1(ns migration.account-summary)
2
3(defn summarize [account orders]
4 (let [open-balance (reduce + 0M (map :order/outstanding-amount orders))
5 warnings (->> orders
6 (filter :order/past-due?)
7 (map #(str "Past due order: " (:order/id %)))
8 vec)]
9 {:account/id (:account/id account)
10 :account/open-balance open-balance
11 :account/over-limit? (> open-balance (:account/credit-limit account))
12 :account/warnings warnings}))
This namespace should not know about JPA, controllers, email, logging frameworks, or feature flags. Those concerns belong in the Java adapter and rollout layer.
An adapter gives Java callers a familiar type while delegating the inner decision to Clojure.
1public final class ClojureAccountSummaryAdapter implements AccountSummaryCalculator {
2 private final IFn summarize;
3
4 public ClojureAccountSummaryAdapter() {
5 Clojure.var("clojure.core", "require")
6 .invoke(Clojure.read("migration.account-summary"));
7 this.summarize = Clojure.var("migration.account-summary", "summarize");
8 }
9
10 @Override
11 public AccountSummary summarize(Account account, List<Order> orders) {
12 Map<Keyword, Object> result = (Map<Keyword, Object>) summarize.invoke(
13 AccountMaps.toMap(account),
14 OrderMaps.toMaps(orders)
15 );
16 return AccountSummaryMaps.fromMap(result);
17 }
18}
The adapter is not the place for cleverness. Keep conversions centralized, name keyword contracts clearly, and test edge cases where Java and Clojure representations differ.
The pure Clojure function can return a decision that Java interprets.
1(defn notification-commands [summary]
2 (cond-> []
3 (:account/over-limit? summary)
4 (conj {:command/type :send-credit-limit-warning
5 :account/id (:account/id summary)})))
Java can execute those commands after the comparison phase. During shadow mode, it can log the planned commands without sending duplicate emails.
| Effect | Unsafe migration move | Safer move |
|---|---|---|
| Send from both Java and Clojure during comparison | Let Clojure return a planned command; Java sends once. | |
| Database write | Write from the new path before equivalence is proven | Compare result maps first; keep old write path. |
| Metrics | Rename or duplicate metric streams immediately | Emit tagged comparison metrics during shadow mode. |
| Queue publish | Publish to the same queue from both implementations | Log planned publish events until cutover. |
A controlled rollout is an engineering artifact, not a meeting note.
| Rollout control | What it should answer |
|---|---|
| Feature flag | Can traffic be routed back to Java immediately? |
| Difference log | Which inputs produced different outputs and why? |
| Ownership | Who reviews production mismatches and approves cutover? |
| Observability | Are latency, error rate, and conversion failures visible? |
| Cleanup plan | When can old Java code be removed safely? |
Once this slice is stable, the team can repeat the loop for adjacent behavior such as invoice aging, eligibility decisions, or import validation.