Move database calls, HTTP requests, logging, time, randomness, and mutable state to explicit boundaries so migrated Clojure code stays testable from Java callers.
Side effects are operations that read from or change the outside world: database writes, HTTP calls, files, logs, clocks, UUID generation, message publishing, shared state, and exceptions used for control flow. Clojure does not eliminate side effects. It makes you decide where they belong.
For Java engineers, the useful migration pattern is a pure core with an effectful shell. Keep business decisions as functions over values. Put I/O, state, and adapters at the boundary where Java and Clojure already have to coordinate.
Java service methods often mix validation, decisions, persistence, and notifications.
1public Receipt submit(Order order) {
2 if (!validator.accepts(order)) {
3 throw new InvalidOrderException(order.id());
4 }
5 Receipt receipt = repository.save(order);
6 publisher.publish("order.submitted", receipt.id());
7 return receipt;
8}
The Clojure equivalent should separate the pure decision from the effectful orchestration.
1(defn submission-decision [order]
2 (if (:order/valid? order)
3 {:decision/type :submit
4 :order order}
5 {:decision/type :reject
6 :reason :invalid-order
7 :order/id (:order/id order)}))
8
9(defn submit-order! [{:keys [save! publish!]} order]
10 (let [decision (submission-decision order)]
11 (case (:decision/type decision)
12 :reject decision
13 :submit (let [receipt (save! (:order decision))]
14 (publish! :order.submitted {:receipt/id (:receipt/id receipt)})
15 {:decision/type :submitted
16 :receipt receipt}))))
The exclamation mark in submit-order! is a naming convention that signals side effects. The pure submission-decision function can be tested without a database, queue, or mock framework.
| Effect | Keep pure core responsible for… | Keep boundary responsible for… |
|---|---|---|
| Database | Deciding what should be stored | Opening connections, transactions, retries, writes |
| HTTP | Building request data and interpreting response data | Network call, timeout, authentication, status handling |
| Time | Accepting a timestamp or clock result as input | Reading the clock |
| Randomness and IDs | Using supplied IDs | Generating UUIDs or random values |
| Logging | Returning enough context for useful messages | Emitting logs with correlation IDs |
| Exceptions | Classifying expected domain failures | Translating boundary failures into operational errors |
If a function both decides what should happen and performs all external actions, tests become slow and brittle. If it returns a decision value, callers can inspect behavior directly.
Atoms are for coordinated updates to a single shared reference. They are not a replacement for local variables in a loop.
1(def metrics (atom {:orders/submitted 0
2 :orders/rejected 0}))
3
4(defn record-decision! [decision]
5 (swap! metrics update
6 (case (:decision/type decision)
7 :submitted :orders/submitted
8 :reject :orders/rejected)
9 inc))
This is reasonable if metrics is application-level state. It would be a poor replacement for a local accumulator inside a data transformation. Local transformations should usually use reduce and return a value.
Java developers often use dependency injection frameworks to supply repositories, clients, and publishers. In Clojure, a simple dependency map often works well.
1(def live-deps
2 {:save! order-repository/save!
3 :publish! event-bus/publish!})
4
5(def test-deps
6 {:save! (fn [order] {:receipt/id "test-1" :order order})
7 :publish! (fn [_topic _payload] nil)})
This pattern keeps production adapters explicit and makes tests direct. You do not need a large framework to pass functions into the code that needs them.
Not every failure should be an exception. Domain outcomes are often easier to test as data. Operational failures may still be exceptions.
| Failure type | Prefer |
|---|---|
| Invalid order, missing required field, rejected customer | Return a structured result such as {:decision/type :reject ...}. |
| Database unavailable | Let the boundary throw or translate into an operational error. |
| HTTP timeout | Return or throw according to the boundary contract. |
| Programmer error or impossible invariant | Throw with ex-info and useful data. |
| Java caller expects checked-style handling | Convert at the Java adapter boundary. |
This distinction prevents normal business outcomes from becoming control-flow exceptions while still respecting operational failures.
When reviewing migrated code, ask:
! function have a small, clear responsibility?These questions matter more than whether the code looks “functional.” A small, honest boundary beats a large function that hides effects behind elegant syntax.
! suffixes to make effectful functions visible to reviewers.