Translate familiar Java classes, methods, interfaces, state, and dependency boundaries into Clojure data, functions, namespaces, protocols, and explicit effect edges.
Mapping Java concepts to Clojure is not a word-for-word translation exercise. The goal is to preserve behavior while changing the design center from class identity to data shape, from methods to functions, and from hidden mutation to explicit state transitions.
A Java engineer already has useful instincts: contracts matter, boundaries matter, tests matter, and runtime behavior on the JVM matters. Clojure keeps those concerns, but it expresses them with different default tools.
The first question is not “What is the Clojure equivalent of this Java class?” The better question is “What responsibility does this Java construct carry?”
| Java construct | Usual design intent | Clojure equivalent to consider |
|---|---|---|
| Class with fields and getters | Named data shape | Map, record, or validated data constructor |
| Instance method | Behavior attached to data | Function that accepts the data explicitly |
| Interface | Contract across implementations | Protocol, multimethod, or plain function map |
| Service object | Named operational boundary | Namespace of functions plus explicit dependencies |
| Mutable field | Time-varying value | Return a new value, or use atom/ref only at a real state boundary |
| Exception-driven branch | Error control flow | Return structured results, throw at boundaries, or use exceptions for exceptional cases |
The mapping depends on the role. A Java Customer entity may become a Clojure map. A Java PaymentGateway interface may stay a Java interface at the boundary, become a Clojure protocol, or simply become a function passed into the workflow.
Java often bundles data and behavior into one class.
1public final class InvoiceLine {
2 private final String sku;
3 private final int quantity;
4 private final BigDecimal unitPrice;
5
6 public BigDecimal total() {
7 return unitPrice.multiply(BigDecimal.valueOf(quantity));
8 }
9}
In Clojure, represent the line as data and make the calculation a function.
1(ns billing.invoice)
2
3(defn line-total [{:line/keys [quantity unit-price]}]
4 (* quantity unit-price))
5
6(defn invoice-total [lines]
7 (reduce + 0M (map line-total lines)))
The Clojure version is easier to test because line-total does not require object construction, mocking, or hidden lifecycle. It only needs a value with the keys it reads. That flexibility is powerful, but it also creates a responsibility: document expected keys through specs, schemas, tests, constructors, or clear namespace conventions.
A Java method usually has implicit context: this, fields, injected dependencies, and sometimes inherited behavior. A Clojure function should make its inputs visible.
1public boolean canShip(Order order) {
2 return inventory.hasStock(order.sku())
3 && fraudService.accepts(order.customerId())
4 && order.total().compareTo(BigDecimal.ZERO) > 0;
5}
The functional equivalent separates the pure decision from the effectful checks.
1(defn positive-total? [order]
2 (pos? (:order/total order)))
3
4(defn can-ship? [{:keys [has-stock? accepts-customer?]} order]
5 (and (has-stock? (:order/sku order))
6 (accepts-customer? (:customer/id order))
7 (positive-total? order)))
This is not just different syntax. The dependencies are now explicit. Tests can pass small functions instead of mocks, and production code can pass adapters that call Java services.
Do not introduce a protocol every time Java had an interface. In Clojure, a plain function is often enough.
| Need | Prefer |
|---|---|
One injected operation such as sendEmail |
Pass a function. |
| Several operations that travel together | Pass a map of functions. |
| Polymorphism across data types you own | Consider a protocol. |
| Polymorphism chosen by data values | Consider a multimethod or explicit dispatch table. |
| Java callers require an interface | Keep or implement the Java interface at the boundary. |
Protocols are useful, but they are not a default replacement for every object-oriented abstraction. Start with functions and data. Add protocol machinery when the polymorphism is real and improves the boundary.
Java uses private fields to protect invariants. Clojure uses immutable values, validation, constructors, and boundary discipline. That means invalid values are still possible unless you decide where validation happens.
1(defn make-invoice-line [{:line/keys [sku quantity unit-price] :as line}]
2 (when-not (pos-int? quantity)
3 (throw (ex-info "Quantity must be positive" {:line line})))
4 (when-not (pos? unit-price)
5 (throw (ex-info "Unit price must be positive" {:line line})))
6 {:line/sku sku
7 :line/quantity quantity
8 :line/unit-price unit-price})
For migration work, validation is especially important at Java-Clojure boundaries. Once data is inside the Clojure core, prefer simple immutable maps. At the edge, be stricter about shape and error reporting.