Understand when a shared runtime resource is legitimate in Clojure and when singleton-style access is a design smell.
The singleton pattern promises one shared instance and a global access path. In Clojure, that idea deserves extra scrutiny because global state is one of the fastest ways to make otherwise clear functional code harder to test and harder to reason about.
That does not mean the problem never exists. Some resources really are shared:
The practical question is not, “How do I recreate the classic singleton?” It is, “Do I truly need one shared runtime resource, and if so, how can I make its ownership explicit and safe?”
Many singleton requests are really symptoms of one of these design problems:
Once a value becomes globally reachable, tests start depending on execution order, reload behavior becomes trickier, and local reasoning gets weaker. That is especially costly in Clojure, where the language otherwise makes explicit data flow easy.
A shared resource is legitimate when all of these are true:
That is closer to “shared runtime resource” than to “magic global object.”
defonce And delay Over Ad-Hoc Global MutationIf you really need one lazily created resource per running process, defonce plus delay is often clearer than a hand-rolled atom-based singleton.
1(ns app.db)
2
3(defn connect-db []
4 {:pool :connected})
5
6(defonce db-pool
7 (delay (connect-db)))
8
9(defn get-db-pool []
10 @db-pool)
This works well because:
defonce avoids recreating the root var on normal reloadsdelay guarantees lazy, at-most-once realization per processThat is usually cleaner than:
nil? checksUse an atom only when the shared value itself must change over time and that is part of the design, not just part of initialization.
1(defonce current-config
2 (atom {:log-level :info
3 :feature-flags #{}}))
That is no longer just a singleton. It is a shared mutable reference with all the trade-offs that implies. Make that distinction explicit in naming and design review.
Even if the program has one shared resource, most business functions should still receive that resource explicitly rather than reaching for a global.
1(defn find-user [db user-id]
2 ;; query using db
3 {:user/id user-id})
4
5(defn handle-request [db request]
6 (find-user db (:user-id request)))
That keeps the shared resource at the system edge while preserving testable, local business logic in the middle.
The first mistake is treating any widely used value as a singleton candidate. Frequency of use is not a sufficient reason for global access.
The second mistake is using defonce to hide mutable state that should have stayed explicit.
The third mistake is assuming “only one instance” automatically means “best accessed globally.” Those are separate decisions.
Before using a singleton-style pattern, consider:
delay for one-time lazy creationIn Clojure, these options usually produce clearer code than a traditional global singleton port.
Ask these before accepting singleton-style code:
delay solve the problem more cleanly than an atom or var?If the answers are weak, you probably do not need a singleton at all.