Browse Learn Clojure Foundations as a Java Developer

Migrate Java Code to Clojure Incrementally

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.

Pick A Slice, Not A Subsystem

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.

Use Dual Implementations Deliberately

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.

Roll Out Through A Switch

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.

Keep Data Shape Stable At The Boundary

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.

Observe Before Removing The Old Path

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:

  • fixture comparisons passed for representative data
  • integration tests passed through the Java adapter
  • production logs show no unexpected boundary failures
  • metrics show acceptable latency, error rates, and allocation behavior
  • on-call engineers know where the migrated code lives and how to debug it
  • at least two maintainers can review future changes

If these are not true, keep the fallback path longer. Cleanup should be earned.

Practice

  1. Choose one Java interface or service method that can delegate to a Clojure implementation.
  2. Define the Java-facing contract and the internal Clojure data shape separately.
  3. Write three fixtures and compare Java and Clojure results.
  4. Design a rollout switch and a rollback sentence.

Key Takeaways

  • Incremental migration moves behavior through small, reversible slices.
  • Dual implementations are useful for equivalence testing, not permanent duplication.
  • Keep Java-facing contracts stable while Clojure internals improve.
  • Rollout should move through test, shadow, partial, default, and cleanup stages.
  • Remove the Java path only after tests, production evidence, and ownership are stable.

Quiz: Incremental Migration Strategies

### What is a good first migration slice? - [x] A pure rule or data transform with a clear Java boundary. - [ ] An entire subsystem with weak tests. - [ ] A transaction path with irreversible writes and no dry run. - [ ] A framework lifecycle callback with hidden behavior. > **Explanation:** Clear, testable slices reduce migration risk and teach the boundary mechanics. ### Why keep Java and Clojure implementations side by side temporarily? - [x] To compare behavior from the same fixtures. - [ ] To double the permanent maintenance cost. - [ ] To avoid writing tests. - [ ] To remove the need for rollout controls. > **Explanation:** Dual implementation is useful as an equivalence strategy during migration. ### What should not be duplicated during shadow comparison? - [x] Writes or irreversible side effects. - [ ] Pure calculations. - [ ] Read-only derived results. - [ ] Fixture comparisons. > **Explanation:** Duplicating writes can corrupt state or create duplicate external effects. ### Why keep the Java-facing data contract stable? - [x] It lets callers remain stable while Clojure internals evolve. - [ ] Clojure cannot use maps. - [ ] Java cannot call interfaces. - [ ] It prevents testing. > **Explanation:** Stable boundaries reduce the number of variables in each release. ### When should the old Java path be removed? - [x] After tests, production evidence, rollback confidence, and ownership are stable. - [ ] Immediately after the Clojure code compiles. - [ ] Before any production observation. - [ ] Before the team can review Clojure changes. > **Explanation:** Cleanup should follow evidence, not optimism.
Revised on Saturday, May 23, 2026