Connect Clojure to existing Java systems safely by choosing stable boundaries, converting data deliberately, preserving caller contracts, and isolating effects during migration.
Most production Clojure adoption starts inside an existing Java system. The integration problem is not whether Clojure can call Java. It can. The real problem is deciding which boundary should stay stable while Clojure takes over a small, valuable behavior.
Good integration design keeps the old system boring. Java callers should not have to understand Clojure maps, lazy sequences, or keyword names unless the public contract intentionally changes.
| Boundary style | Use it when, and watch for |
|---|---|
| Direct Java interop | Use when Clojure needs to reuse a Java library or service object in-process. Watch for Java types leaking deep into Clojure logic. |
| Java adapter calls Clojure | Use when existing Java callers need stable method signatures. Watch for conversion code becoming scattered. |
| HTTP API | Use when the Clojure code can be deployed as a separate service. Watch for network, serialization, and operations overhead. |
| Message queue | Use when the work is asynchronous or event-driven. Watch delivery semantics and duplicate handling. |
| Shared database | Use when both systems already depend on the same persisted model. Watch for coupling moving into schemas and transactions. |
Prefer the smallest boundary that gives you safe rollout and clear ownership. Do not create a distributed system just to run one pure function.
A Java adapter can preserve an existing interface while delegating the decision to Clojure.
1public interface EligibilityService {
2 EligibilityResult check(Customer customer, Policy policy);
3}
1public final class ClojureEligibilityService implements EligibilityService {
2 private final IFn checkEligibility;
3
4 public ClojureEligibilityService(IFn checkEligibility) {
5 this.checkEligibility = checkEligibility;
6 }
7
8 @Override
9 public EligibilityResult check(Customer customer, Policy policy) {
10 Map<Keyword, Object> result = (Map<Keyword, Object>) checkEligibility.invoke(
11 CustomerMaps.toMap(customer),
12 PolicyMaps.toMap(policy)
13 );
14 return EligibilityResults.fromMap(result);
15 }
16}
The adapter has one job: convert representations and preserve the existing contract. The business rule should live in Clojure as plain data logic.
1(defn check-eligibility [customer policy]
2 (cond
3 (:customer/suspended? customer)
4 {:eligibility/status :denied
5 :eligibility/reason :customer-suspended}
6
7 (> (:policy/risk-score policy) 700)
8 {:eligibility/status :review
9 :eligibility/reason :high-risk}
10
11 :else
12 {:eligibility/status :approved}))
This keeps Java callers stable while giving Clojure a small, testable core.
Interop bugs often come from casual conversion.
| Java shape | Clojure mapping and review concern |
|---|---|
BigDecimal |
Keep decimal values rather than floating point, and preserve scale and rounding rules. |
Optional<T> |
Convert to a value or nil at the boundary; do not let nested optionals leak inward. |
List<T> |
Use a vector or sequence, and realize lazy results before returning to Java when needed. |
| Enums | Convert to keywords with a reversible mapping. |
| Exceptions | Use ex-info or result maps depending on failure type; do not hide broken system conditions as normal domain results. |
Put these choices in one adapter namespace or class pair. If every caller invents its own mapping, the migration becomes fragile.
When Clojure participates in an existing system, side effects need clear ownership.
| Effect | Safer integration rule |
|---|---|
| Database writes | Let one side own the transaction for a given slice. |
| Email or notifications | Return planned commands during comparison; send once at the approved boundary. |
| Metrics | Tag migration paths so Java and Clojure behavior can be compared. |
| Logging | Include correlation IDs from the Java caller. |
| Retries | Keep retry ownership in the orchestration layer, not inside pure functions. |
If both Java and Clojure perform the same effect during shadow mode, you are no longer comparing behavior safely.
The first adapter is often clean. The tenth adapter can become a mess unless the team sets rules.
| Drift symptom | Correction |
|---|---|
| Clojure functions accept Java objects directly everywhere | Convert at the boundary, then use maps or records internally. |
| Java code reads Clojure maps in many call sites | Convert back to Java result types at the adapter. |
| Each namespace defines different keyword names | Establish a data contract and reuse it. |
| Exceptions cross language boundaries without context | Wrap failures with enough data for operations and debugging. |
| Shadow comparison sends duplicate effects | Split decisions from commands before comparing. |
Integration succeeds when each side has a clear responsibility. Java can orchestrate and preserve contracts. Clojure can express behavior over values.