Move from Java to Clojure in small, reversible slices using adapters, dual implementations, fixture comparison, feature flags, and production observation.
Incremental migration is the default strategy for serious Java-to-Clojure adoption. It lets Java keep owning stable production boundaries while Clojure proves value behind those boundaries one slice at a time.
The point is not to move slowly forever. The point is to reduce the number of variables in each release: one seam, one behavior group, one adapter, one rollback path.
A good migration slice has a small contract and visible behavior. It can be called from Java, tested from fixtures, and observed in production.
| Slice type | Good first use | Poor first use |
|---|---|---|
| Pure rule function | Pricing, validation, eligibility, routing decisions | Hidden behavior buried in framework callbacks |
| Data transform | Import normalization, report rows, event payloads | Streaming job with unmanaged resource lifetimes |
| Java adapter | One service interface delegates to Clojure | Adapter hides many unrelated responsibilities |
| Batch step | Reconciliation or cleanup logic with replayable inputs | Irreversible writes without dry-run support |
| Read-only path | Query formatting or derived data | Transactional command path with weak tests |
Start with slices that teach the team how Java and Clojure interact without requiring a full architecture migration.
For a short period, keep Java and Clojure implementations side by side. This is not duplication for its own sake. It is an equivalence tool.
1(defn compare-results [java-result clojure-result]
2 {:equivalent? (= java-result clojure-result)
3 :java java-result
4 :clojure clojure-result})
In tests, compare both implementations against the same fixtures. In production, only run dual paths if the operation is safe, read-only, or explicitly designed for shadow comparison. Never duplicate writes just to compare behavior.
A feature flag, configuration switch, adapter selection, or deployment rollback lets the team control exposure.
| Rollout stage | What to do |
|---|---|
| Test only | Clojure implementation exists but only runs in tests and local REPL sessions. |
| Shadow read | Clojure computes a result in a safe path and differences are logged or counted. |
| Internal traffic | Selected users, jobs, or environments use the Clojure path. |
| Partial production | A small percentage or one bounded workflow uses Clojure. |
| Default path | Clojure becomes the normal implementation; Java fallback remains briefly. |
| Cleanup | Remove Java fallback after evidence and ownership are stable. |
The switch should be boring. If the switch itself is complex, it becomes another migration risk.
Most incremental migrations fail when the team changes boundary data shape and implementation behavior at the same time. Keep the Java-facing contract stable while the Clojure core evolves behind it.
1public interface EligibilityPolicy {
2 EligibilityDecision evaluate(Customer customer, Offer offer);
3}
The Clojure implementation can convert Java objects at the edge, then operate on maps internally.
1(defn customer->data [customer]
2 {:customer/id (.id customer)
3 :customer/tier (keyword (.tier customer))
4 :customer/active? (.isActive customer)})
5
6(defn offer->data [offer]
7 {:offer/id (.id offer)
8 :offer/min-tier (keyword (.minTier offer))})
This keeps Java callers stable while Clojure functions become simpler to test.
Migration is not complete when the Clojure code compiles. It is complete when the team has enough evidence to remove the old path safely.
Collect evidence such as:
If these are not true, keep the fallback path longer. Cleanup should be earned.