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.
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.
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.
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.
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.
| 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.