Browse Learn Clojure Foundations as a Java Developer

Debug and Handle Errors in Migrated Clojure Code

Debug Clojure inside Java systems by reading stack traces, inspecting data at the REPL, using ex-info for contextual failures, and separating domain outcomes from broken system conditions.

Debugging Clojure in a Java system is less mysterious when you remember that both languages run on the JVM. Stack traces, exceptions, logging, profilers, and thread dumps still matter. What changes is how much of the code can be inspected as data and re-run at the REPL.

The best migration debugging strategy is to make the Clojure slice small, pure, and observable before it owns production effects.

Read The Failure Boundary

When Clojure fails inside Java, first locate the boundary.

Failure location Likely cause and first check
Java caller before Clojure invocation Adapter wiring, namespace loading, classpath, or feature flag. Confirm the Clojure namespace is loaded and the function var is resolved.
Java-to-Clojure conversion Nulls, money values, dates, enums, or collections. Inspect the map passed into the Clojure function.
Pure Clojure function Business rule, missing key, invalid shape, or numeric behavior. Re-run the function at the REPL with the captured input.
Clojure-to-Java conversion Missing output key or unexpected result type. Validate the output contract before converting.
Effect boundary Database, queue, email, HTTP, retry, or timeout. Confirm the pure decision and effect command separately.

This boundary-first approach prevents random debugging. It tells you whether the bug is in orchestration, conversion, decision logic, or effects.

Prefer Reproducible Data

Instead of stepping through a large service graph, capture the input map that reached the Clojure function.

1(def failing-input
2  {:customer {:customer/id "C-42"
3              :customer/credit-limit 1000M}
4   :orders [{:order/id "O-1"
5             :order/outstanding-amount 250M
6             :order/past-due? true}
7            {:order/id "O-2"
8             :order/outstanding-amount nil
9             :order/past-due? false}]})

Now the failure can be reproduced without the controller, repository, transaction, and email service.

1(account-summary (:customer failing-input)
2                 (:orders failing-input))

If this fails, the team can decide whether nil is invalid input, a conversion bug, or a domain case the function should handle.

Use ex-info For Context

Java developers are used to exception classes carrying meaning. In Clojure, ex-info lets you attach structured data to a failure.

1(defn require-money [value path]
2  (if (some? value)
3    value
4    (throw (ex-info "Missing money value"
5                    {:error/type :invalid-input
6                     :error/path path}))))

Java can still catch the exception. Clojure code can inspect the data with ex-data.

1(try
2  (require-money nil [:orders 1 :order/outstanding-amount])
3  (catch clojure.lang.ExceptionInfo ex
4    (ex-data ex)))

Use structured exception data for broken assumptions, not for every expected domain result.

Separate Domain Outcomes From System Failures

Not every unhappy path should be an exception.

Situation Better Clojure shape
Customer is not eligible Return {:status :denied :reason ...}.
Input map is missing a required key Throw ex-info or reject at boundary validation.
External service times out Return or throw according to the orchestration policy, with context.
Business rule requires manual review Return {:status :review :reason ...}.
Adapter cannot convert a Java enum Fail fast with structured exception data.

Expected business outcomes should usually be values. Broken system conditions should be visible failures.

REPL Debugging In A Migration Slice

Use the REPL to reduce the bug to one expression.

1(->> (:orders failing-input)
2     (map :order/outstanding-amount)
3     (remove nil?)
4     (reduce + 0M))

Then decide whether removing nil is correct. The REPL makes it easy to test a fix; code review still decides whether the fix matches the domain.

REPL move Use it for
Evaluate a subexpression Find which transformation breaks.
Bind a captured fixture Reproduce production-shaped data.
Inspect intermediate values Confirm map keys, types, and sequence contents.
Try a smaller function Avoid stepping through framework code.
Add a regression test Preserve the discovered case.

Do not leave the bug fix as REPL history. Convert the captured case into a test.

Logging And Observability

Debugging production migrations requires correlation, not print statements scattered everywhere.

Log field Why it helps
Request or job ID Connect Java and Clojure logs.
Migration slice name Know which adapter or feature flag path ran.
Input identifier Reproduce the case without logging sensitive payloads.
Output status Compare old and new behavior safely.
Exception data Preserve structured context from ex-info.

Use println locally when it is the fastest way to understand a pure function. Use structured logging and metrics for production behavior.

Practice

  1. Capture one failing Java-to-Clojure input as EDN.
  2. Reproduce the failure at the REPL without the Java service graph.
  3. Decide whether the failure is invalid input, a domain result, or a broken system condition.
  4. Add either a value-returning domain test or an ex-info failure test.

Key Takeaways

  • Start debugging by locating the failure boundary.
  • Captured Clojure data makes production-shaped failures reproducible.
  • ex-info is useful when an exception needs structured context.
  • Domain outcomes should often be values; broken assumptions should fail visibly.
  • Every REPL-discovered bug should become a regression test.

Quiz: Debugging And Error Handling

### What is the first debugging question when Clojure fails inside Java? - [x] Which boundary failed: caller, conversion, pure function, output conversion, or effect? - [ ] Which macro should replace the function? - [ ] How can the stack trace be ignored? - [ ] How many lines of Java can be deleted? > **Explanation:** Locating the boundary narrows the bug before changing code. ### Why capture Clojure input as data? - [x] It lets the team reproduce the failure at the REPL without the full Java service graph. - [ ] It prevents Java from catching exceptions. - [ ] It removes the need for tests. - [ ] It guarantees the input is valid. > **Explanation:** A captured map or vector turns a production-shaped bug into a small reproducible case. ### When is `ex-info` appropriate? - [x] When a failure needs exception semantics plus structured diagnostic data. - [ ] For every normal business outcome. - [ ] Only when Java interop is not used. - [ ] To hide all stack traces. > **Explanation:** `ex-info` carries a message and data map, which helps adapters and logs preserve context. ### What should happen after a REPL session reveals the bug? - [x] Add a regression test for the captured case. - [ ] Leave the fix only in REPL history. - [ ] Delete the adapter. - [ ] Convert all domain outcomes to exceptions. > **Explanation:** The REPL is a discovery tool; the test preserves the discovery for future changes.
Revised on Saturday, May 23, 2026