Browse Learn Clojure Foundations as a Java Developer

Migrate a Java Application in Controlled Slices

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.

Migration Loop

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.

Baseline The Java Behavior

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.

Implement The Clojure Core

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.

Add A Java Adapter

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.

Control Effects

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
Email 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.

Roll Out The Slice

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.

Practice

  1. Choose one Java method and write a fixture that captures current behavior.
  2. Define the Clojure input and output maps before implementing the function.
  3. Sketch the Java adapter that preserves the existing caller contract.
  4. List every external effect and decide how it will be suppressed during shadow comparison.

Key Takeaways

  • Migrate one behavior slice at a time.
  • Baseline Java behavior before writing Clojure.
  • Keep the Clojure core pure and small enough to test without framework setup.
  • Put representation conversion in an adapter, not across the whole codebase.
  • Treat shadow comparison, feature flags, and rollback as part of the migration design.

Quiz: Controlled Migration Process

### What should happen before implementing the Clojure version of a behavior? - [x] Capture the current Java behavior with reviewed fixtures. - [ ] Delete the Java method. - [ ] Move database ownership to Clojure. - [ ] Add macros for all business rules. > **Explanation:** Fixtures give the team concrete behavior to preserve, compare, or intentionally change. ### What belongs inside the first Clojure core namespace? - [x] Pure decisions over values. - [ ] JPA transaction management. - [ ] Email delivery clients. - [ ] HTTP controller annotations. > **Explanation:** The first core should be easy to test and compare without framework-owned effects. ### Why use a Java adapter during migration? - [x] It keeps existing Java callers stable while Clojure owns inner behavior. - [ ] It prevents Clojure from using maps. - [ ] It replaces all tests. - [ ] It removes the need for rollback. > **Explanation:** The adapter preserves the production contract and centralizes Java-to-Clojure conversion. ### What is the safe way to handle effects during shadow comparison? - [x] Return planned commands and execute only the approved production path. - [ ] Send every email from both implementations. - [ ] Disable all logging. - [ ] Ignore mismatches until cutover. > **Explanation:** Shadow mode should compare behavior without duplicating writes, emails, queue messages, or other external effects.
Revised on Saturday, May 23, 2026