Browse Learn Clojure Foundations as a Java Developer

Prove Java and Clojure Functional Equivalence

Verify migrated Clojure behavior against Java by capturing golden fixtures, comparing normalized outputs, handling nondeterminism, testing adapters, and documenting intentional differences.

Functional equivalence means the migrated Clojure path preserves the Java behavior that callers and users depend on. It does not mean the internal design looks the same. In a good migration, Java classes may become Clojure functions and data, but the externally visible behavior must still be defensible.

For Java engineers, the safest validation strategy is to compare behavior at stable boundaries: method input/output pairs, HTTP request/response pairs, events, reports, or database-visible outcomes.

Equivalence Is A Contract

Define the contract before arguing about implementation details.

Contract area What to compare
Inputs Required fields, optional fields, null handling, enum values, dates, money, and collection ordering.
Outputs Result values, errors, warnings, status codes, events, and persisted state.
Side effects Writes, notifications, queue messages, metrics, and audit records.
Ordering Sort order, tie-breaking, pagination, and deterministic output shape.
Failure behavior Expected domain failures versus broken system conditions.

If a behavior is intentionally changed, record it as a migration decision. Do not let it appear as an unexplained mismatch.

Start With Golden Fixtures

A golden fixture is a reviewed input/output example from the old Java path.

 1(def over-limit-fixture
 2  {:input {:customer/id "C-42"
 3           :customer/credit-limit 1000M
 4           :orders [{:order/id "O-1"
 5                     :order/amount 700M}
 6                    {:order/id "O-2"
 7                     :order/amount 450M}]}
 8   :expected {:summary/customer-id "C-42"
 9              :summary/open-balance 1150M
10              :summary/status :over-limit}})

The Clojure test should prove the new function preserves that behavior.

1(deftest preserves-over-limit-summary
2  (let [{:keys [input expected]} over-limit-fixture]
3    (is (= expected (summarize-account input)))))

Do not generate fixtures from the new code. Capture them from the Java behavior or from reviewed business examples.

Compare Old And New Paths

During migration, it is often useful to run both implementations against the same input and compare normalized results.

1(defn equivalent-summary? [java-summary clojure-summary]
2  (= (select-keys java-summary [:summary/customer-id
3                                :summary/open-balance
4                                :summary/status])
5     (select-keys clojure-summary [:summary/customer-id
6                                   :summary/open-balance
7                                   :summary/status])))

Normalization is important because Java and Clojure may represent the same business result differently. Normalize deliberately. Do not normalize away meaningful differences such as rounding, status codes, or missing warnings.

Difference Treat as
Map key order differs Usually irrelevant if the public format does not promise order.
Money rounding differs A real mismatch unless the business rule changed intentionally.
Warning text differs Review with users or downstream consumers.
Extra audit event appears A side-effect mismatch that can affect operations.
Java returns null and Clojure returns nil internally Fine inside the adapter if the public contract is preserved.

Use Property Checks Carefully

Property-based testing can find edge cases that example tests miss, but properties must be grounded in the migration contract.

 1(def positive-money-gen
 2  (gen/fmap bigdec (gen/choose 0 100000)))
 3
 4(def summary-total-property
 5  (prop/for-all [amounts (gen/vector positive-money-gen)]
 6    (let [orders (map-indexed (fn [idx amount]
 7                                {:order/id (str "O-" idx)
 8                                 :order/amount amount})
 9                              amounts)
10          summary (summarize-account {:customer/id "C-1"
11                                      :customer/credit-limit 999999M
12                                      :orders orders})]
13      (= (:summary/open-balance summary)
14         (reduce + 0M amounts)))))

Use generated checks for invariants such as totals, ordering rules, idempotence, and validation behavior. Do not use them to avoid writing business examples.

Validate Adapters Separately

Many migration bugs live in conversion code, not in Clojure’s pure logic.

Adapter case Why it matters
Java nulls Decide where null becomes nil, rejection, or default data.
BigDecimal values Preserve precision, scale, and rounding expectations.
Enums Map to keywords and back without losing unsupported cases.
Lists and lazy sequences Realize results before returning to Java when a concrete collection is expected.
Exceptions Preserve enough context for Java callers and logs.

Keep adapter tests close to the boundary. Core Clojure tests should not need Java service graphs.

Equivalence Checklist

Use this before cutover:

  1. Golden fixtures cover normal, boundary, and failure cases.
  2. Java and Clojure paths are compared against the same inputs.
  3. Normalization rules are explicit and reviewed.
  4. Adapter conversion tests cover nulls, money, enums, dates, and collections.
  5. Side effects are compared without duplicating production writes or notifications.
  6. Intentional differences are documented with owner approval.

Functional equivalence is not a one-time assertion. Keep a small equivalence suite after cutover so future refactors do not reopen migration bugs.

Practice

  1. Pick one Java method and capture three golden fixtures: normal, boundary, and failure.
  2. Write a Clojure test that compares the migrated result to the expected value.
  3. Identify one difference that should be normalized and one that should not.
  4. Add one adapter test for null, money, enum, date, or collection conversion.

Key Takeaways

  • Functional equivalence means preserving observable behavior, not preserving Java internals.
  • Golden fixtures should come from the old Java behavior or reviewed business examples.
  • Normalize representation differences deliberately, not casually.
  • Property checks are useful when they express real migration invariants.
  • Adapter conversion deserves separate tests because many migration defects happen at boundaries.

Quiz: Functional Equivalence

### What should functional equivalence preserve? - [x] Observable behavior at stable boundaries. - [ ] The original Java class structure. - [ ] The same number of source files. - [ ] Every internal mutable variable. > **Explanation:** Migration can change implementation shape, but callers and users depend on observable behavior. ### Why should golden fixtures not be generated from the new Clojure code? - [x] They should represent the old Java behavior or reviewed business expectations. - [ ] Clojure cannot read fixture data. - [ ] Fixtures are only useful for performance tests. - [ ] Java tests cannot contain examples. > **Explanation:** Fixtures are evidence for equivalence only if they come from the behavior being preserved. ### Which difference should usually be treated as a real mismatch? - [x] Money rounding changes. - [ ] Internal map key order changes where order is not public. - [ ] Java null becomes internal Clojure `nil` before conversion. - [ ] The Clojure function has fewer local variables. > **Explanation:** Numeric behavior is usually part of the business contract and must be reviewed explicitly. ### Where do many migration bugs occur? - [x] In adapter conversion between Java objects and Clojure values. - [ ] Only in namespace declarations. - [ ] Only in comments. - [ ] Only after all Java code is deleted. > **Explanation:** Nulls, money, enums, dates, collections, and exception data often fail at the language boundary.
Revised on Saturday, May 23, 2026