Browse Learn Clojure Foundations as a Java Developer

Keep Logging and I/O Safe Under Concurrency

Handle Clojure logging, file I/O, request context, and Java logging framework boundaries without hiding blocking effects inside retryable state updates.

Logging and I/O are side effects by definition. They touch files, sockets, databases, consoles, queues, or external systems. In concurrent Clojure, the goal is not to make them “functional.” The goal is to keep them at explicit boundaries with predictable threading, failure, and backpressure behavior.

Java experience helps here. Logging frameworks, appenders, JDBC pools, HTTP clients, executors, and file handles all have operational contracts. Clojure code should respect those contracts while keeping pure application logic separate.

Logging Is A Boundary

Clojure commonly uses clojure.tools.logging as a facade over JVM logging implementations.

1(ns payments.logging
2  (:require [clojure.tools.logging :as log]))
3
4(defn log-payment-decision! [decision payment]
5  (log/info "payment decision"
6            {:decision decision
7             :payment-id (:id payment)}))

The logging call is effectful. Keep it out of retryable atom updates and STM transactions.

1(defn handle-payment! [payment]
2  (let [decision (classify-payment payment)]
3    (record-decision! decision payment)
4    (log-payment-decision! decision payment)
5    decision))

If record-decision! uses swap! or dosync, logging should happen before or after that retryable region, not inside it.

Preserve Request Context Explicitly

Java teams often rely on thread-local logging context. In Clojure, be careful when work moves across futures, agents, executors, or Java callbacks. Thread-local context may not follow automatically.

Prefer explicit context data:

1(defn audit-event [ctx event]
2  {:request-id (:request-id ctx)
3   :user-id (:user-id ctx)
4   :event event})
5
6(defn submit-audit! [audit-agent ctx event]
7  (send audit-agent conj (audit-event ctx event)))

Passing context as data is less magical and more reliable than assuming the same thread will run later work.

File I/O Must Be Realized

Lazy file reads are a common trap. If a lazy sequence escapes with-open, the reader is already closed when the sequence is consumed.

1(ns files.safe
2  (:require [clojure.java.io :as io]))
3
4(defn read-lines! [path]
5  (with-open [reader (io/reader path)]
6    (doall (line-seq reader))))

The doall is intentional: it realizes the lines while the reader is open. For large files, stream through a reducing function instead of returning all lines.

1(defn count-matching-lines! [path pred]
2  (with-open [reader (io/reader path)]
3    (reduce (fn [n line]
4              (if (pred line) (inc n) n))
5            0
6            (line-seq reader))))

The function is effectful because it reads a file, but the predicate can remain pure and testable.

Do Not Hide Blocking In State Updates

This is unsafe:

1(def state (atom {:processed 0}))
2
3(defn bad-process! [record]
4  (swap! state
5         (fn [s]
6           (spit "processed.log" (str record "\n") :append true)
7           (update s :processed inc))))

The write can repeat on retry and can block other updates. Use a boundary:

1(defn process! [record]
2  (let [new-state (swap! state update :processed inc)]
3    (spit "processed.log" (str record "\n") :append true)
4    new-state))

For critical logs or audit rows, this still may not be enough. Use durable storage, an append-only event table, or a queue when losing or duplicating records is unacceptable.

Operational Choices

Effect Common Clojure boundary What to decide
Application logging clojure.tools.logging facade Sync or async appender, context propagation, log volume
File read with-open, realized sequence or reducer Memory use, resource lifetime, error handling
File write spit, writer, Java NIO, or logging framework Append safety, buffering, flush behavior
Database write JDBC/next.jdbc boundary Transaction scope, idempotency, connection pool behavior
HTTP call Client boundary function Timeout, retry, cancellation, circuit breaker policy
Queue publish Broker or Java queue boundary Delivery guarantee, backpressure, dead-letter handling

These choices are not cosmetic. They are where production behavior lives.

Knowledge Check

### Why should logging usually stay outside `swap!` and `dosync` bodies? - [x] Those bodies may retry, causing duplicated log or I/O effects - [ ] Logging cannot be called from Clojure - [ ] Logs are immutable values - [ ] `dosync` only supports strings > **Explanation:** Retryable state coordination can rerun the body. Observable effects inside can happen more than once. ### Why should request context be passed as data to asynchronous work? - [x] Thread-local context may not follow futures, agents, executors, or callbacks - [ ] Clojure maps cannot be logged - [ ] Agents require global vars - [ ] Java logging frameworks do not support context > **Explanation:** Explicit context travels with the event. Thread-local context depends on which thread executes the later work. ### What problem does `doall` solve in the `read-lines!` example? - [x] It realizes the lazy line sequence while the reader is still open - [ ] It makes file reading asynchronous - [ ] It locks the file forever - [ ] It converts the file into an atom > **Explanation:** `line-seq` is lazy. Without realization inside `with-open`, consumers may try to read after the reader has closed.
Revised on Saturday, May 23, 2026