Build a practical migration profile for a Java application by mapping architecture, data flow, side effects, risk, and the Clojure seams that can be introduced without destabilizing production.
A useful migration case study begins with the application, not with Clojure syntax. Java engineers need to know which parts of the system are stable, which parts are painful, and which boundaries can safely host Clojure code before any rewrite starts.
This example uses a common enterprise shape: a customer operations application with Java web controllers, service classes, JPA repositories, scheduled jobs, and integrations with billing and email systems. The exact domain is less important than the migration profile: where behavior lives, where effects happen, and where tests can prove equivalence.
The first pass is a factual map of the current Java system.
| Area | Current Java shape | Migration meaning |
|---|---|---|
| Web entry points | Controllers receive requests and call service classes | Keep these stable while introducing Clojure behind adapters. |
| Domain services | Classes coordinate validation, pricing, status changes, and notifications | Good candidates if behavior can be isolated from persistence. |
| Persistence | JPA repositories and transactions | Usually keep Java-owned during early slices. |
| Integrations | Email, billing, metrics, and queue clients | Treat as effectful boundaries, not pure migration targets. |
| Scheduled work | Java jobs process batches and publish reports | Often a good place for pure Clojure transformations plus Java scheduling. |
This snapshot prevents a common mistake: rewriting the part that looks interesting instead of the part that is safe, measurable, and valuable.
A Java service often combines data retrieval, business decisions, mutation, and side effects in one method.
1public AccountSummary summarizeAccount(long accountId) {
2 Account account = accountRepository.findById(accountId);
3 List<Order> orders = orderRepository.findOpenOrders(accountId);
4
5 BigDecimal openBalance = BigDecimal.ZERO;
6 List<String> warnings = new ArrayList<>();
7
8 for (Order order : orders) {
9 openBalance = openBalance.add(order.getOutstandingAmount());
10 if (order.isPastDue()) {
11 warnings.add("Past due order: " + order.getId());
12 }
13 }
14
15 if (openBalance.compareTo(account.getCreditLimit()) > 0) {
16 notificationService.sendCreditLimitWarning(account);
17 }
18
19 return new AccountSummary(accountId, openBalance, warnings);
20}
The migration question is not “How do we rewrite this method line by line?” It is “Which part is a decision over values, and which part is an effect?”
1(defn account-summary [account orders]
2 (let [open-balance (reduce + 0M (map :order/outstanding-amount orders))
3 warnings (->> orders
4 (filter :order/past-due?)
5 (map #(str "Past due order: " (:order/id %)))
6 vec)]
7 {:account/id (:account/id account)
8 :account/open-balance open-balance
9 :account/warnings warnings
10 :account/over-limit? (> open-balance (:account/credit-limit account))}))
The Clojure function does not query repositories or send email. It makes the decision explicit. The Java service can still own the transaction, repository calls, and notification delivery while the team validates the Clojure behavior.
Not every problem is a Clojure opportunity. Classify each pressure before choosing a slice.
| Pressure | Java symptom | Better first move |
|---|---|---|
| Hidden rules | Business rules scattered across service classes | Extract pure decision functions with fixture tests. |
| Mutable accumulators | Loops build totals, warnings, or reports | Rewrite as reduce, map, filter, or group-by. |
| Heavy framework ownership | Framework annotations define lifecycle and transactions | Keep Java boundary, move only inner behavior. |
| External effects | Method sends email, writes metrics, or publishes queue messages | Return planned commands, execute them at the boundary. |
| Poor observability | Hard to compare old and new behavior | Add snapshot tests and structured result maps first. |
This table becomes the working inventory for the next page’s migration process.
A first Clojure slice should be boring enough to ship and meaningful enough to teach the team.
| Candidate slice | Why it works | What should stay in Java first |
|---|---|---|
| Account summary calculation | It transforms database records into a decision value | Repository access, transaction scope, notification sending |
| Invoice aging report | It is batch-oriented and fixture-friendly | Scheduler, file delivery, production credentials |
| Eligibility decision | It is rule-heavy and easy to compare | Controller contract, audit logging, final persistence |
| Import validation | It turns external records into accepted or rejected values | File upload, storage, retry orchestration |
Avoid choosing the first slice only because it has the most lines of code. Choose a slice where behavior can be compared before and after migration.
For Java engineers, the most important design move is deciding which boundary remains stable.
| Boundary question | Safer answer for the first release |
|---|---|
| Should Java callers change? | No. Keep method signatures or adapters stable. |
| Should database ownership move? | Usually no. Move pure transformation first. |
| Should Clojure send external notifications? | Not until duplicate effects and rollback are controlled. |
| Should all related classes be rewritten together? | No. Migrate one behavior path with tests. |
| Should the team introduce macros or DSLs? | No. Start with plain functions and data. |
The strongest early migration design is often intentionally conservative: Java keeps the shell, Clojure owns a pure core.