Identify Java modules that are safe and valuable to rewrite by checking seams, dependencies, tests, data shape, side effects, and expected maintenance payoff.
Evaluating a Java codebase for Clojure migration is not a search for old code to replace. It is a search for code whose behavior can be isolated, described with data, tested, and improved without destabilizing the rest of the system.
A good first candidate usually has a clear boundary: input values enter, output values leave, and side effects can be wrapped at the edge. A poor first candidate is usually framework-driven, callback-heavy, globally mutable, or tightly coupled to a release path where the team cannot tolerate learning risk.
Migration seam: A boundary where Java and Clojure can coexist while one side delegates to the other through stable inputs and outputs.
For Java engineers, seams often appear as service interfaces, batch jobs, validation utilities, report generators, pricing rules, ingestion transforms, or domain calculators. These areas are good candidates because Clojure can represent the same behavior as functions over immutable maps and vectors while Java keeps owning the surrounding lifecycle.
| Candidate signal | What it means for migration |
|---|---|
| Inputs and outputs are plain values | The behavior can be compared before and after the rewrite without reproducing the entire application runtime. |
| Business rules are mixed with iteration and mutation | Clojure can often make the rule pipeline easier to read and test. |
| Dependencies are mostly data access, logging, or time | Side effects can be passed in or wrapped while pure transformation logic moves first. |
| Existing tests describe behavior | The team has a baseline for functional equivalence. |
| Failures are observable in logs, metrics, or fixtures | The migration can be validated outside production traffic before rollout. |
Avoid starting with code whose behavior is mostly “whatever the framework does.” Controllers, entity lifecycle hooks, reflection-heavy libraries, custom classloaders, or transaction managers may still be migrated later, but they are rarely the cleanest place to learn Clojure.
Many Java classes hide a useful data model behind getters, setters, and method calls. During evaluation, ask what data is actually moving through the module.
1public List<InvoiceEvent> billableEvents(List<Invoice> invoices, Clock clock) {
2 List<InvoiceEvent> events = new ArrayList<>();
3 for (Invoice invoice : invoices) {
4 if (invoice.isBillable() && !invoice.isDisputed()) {
5 events.add(new InvoiceEvent(
6 invoice.getId(),
7 "invoice.billable",
8 clock.instant()));
9 }
10 }
11 return events;
12}
The migration question is not “Can Clojure loop over invoices?” It can. The better question is whether this behavior can be described as a value transformation with time supplied explicitly.
1(defn billable-events [now invoices]
2 (->> invoices
3 (filter #(and (:billable? %) (not (:disputed? %))))
4 (map (fn [invoice]
5 {:invoice/id (:id invoice)
6 :event/type :invoice.billable
7 :created-at (now)}))))
This Clojure version is not automatically better just because it is shorter. It is a better migration candidate if the surrounding code can pass invoice data in, receive event data out, and keep publishing, transactions, and retries at the Java boundary until the team is ready to move them.
Dependency count is less important than dependency shape. A module with three boring dependencies may be safer than one dependency that controls threading, transactions, or request lifecycle.
| Dependency type | Migration risk | Better first move |
|---|---|---|
| Pure utility library | Low | Replace directly only if the Clojure standard library or a small function covers the behavior clearly. |
| Database repository | Medium | Keep persistence in Java first; move query result transformation to Clojure. |
| Framework lifecycle object | High | Wrap the framework boundary and migrate code behind it later. |
| Global singleton or static mutable cache | High | Add an explicit boundary before rewriting behavior. |
| Time, randomness, or UUID generation | Medium | Pass these as functions so tests can control them. |
If a dependency cannot be replaced or wrapped without changing behavior, the candidate is not ready. Add a seam first, then revisit it.
Tests are not optional safety equipment. They define whether the migrated Clojure code still behaves like the Java code under meaningful inputs.
Strong migration tests usually include:
Weak tests do not mean “do not migrate.” They mean “write tests before migrating.” A thin test suite is a reason to slow down, not a reason to trust the rewrite.
For each possible migration target, fill out a small worksheet before writing Clojure code.
| Question | Good answer |
|---|---|
| What behavior is being migrated? | A named rule, transform, workflow step, or service boundary. |
| What inputs and outputs prove equivalence? | JSON fixtures, value maps, database rows, or API payloads with expected results. |
| Which side effects remain outside the Clojure core? | Database writes, message publishing, file I/O, HTTP calls, time, randomness. |
| Who reviews the Clojure code? | At least one developer who understands Clojure data, namespaces, and tests. |
| How is rollback handled? | Java keeps the old implementation or can switch through a feature flag, adapter, or deployment rollback. |
If you cannot answer these questions, the module may still be important, but it is not yet a responsible first migration candidate.