Why too much shared mutable state makes Clojure systems harder to test, reason about, and evolve safely.
Overreliance on global state is an anti-pattern because it turns local reasoning into system-wide reasoning. In Clojure, this often happens when too much behavior depends on vars, root-level atoms, global config maps, or application-wide mutable containers that every namespace can touch.
The code may feel convenient at first. Later it becomes harder to test, harder to compose, and much more fragile under concurrency and change.
The classic version is a global atom or ref holding application state:
1(def app-state (atom {:users []
2 :orders []
3 :metrics {}}))
This can be acceptable for tiny tools or UI-style state containers. It becomes a problem when the rest of the system begins to depend on it everywhere.
Symptoms:
The anti-pattern is not one atom. It is letting one shared state root become the default answer for unrelated concerns.
Global state makes function signatures look simpler while actually making behavior less explicit.
Weak:
1(def ^:dynamic *tax-rate* 0.13)
2
3(defn total-with-tax [subtotal]
4 (* subtotal (+ 1 *tax-rate*)))
The function appears to depend only on subtotal, but it really depends on ambient state too.
More explicit:
1(defn total-with-tax [subtotal tax-rate]
2 (* subtotal (+ 1 tax-rate)))
Now callers can see the dependency, tests become easier, and the function is easier to reuse.
The anti-pattern is hiding required inputs behind global availability.
Sometimes global state becomes the accidental integration mechanism between parts of the application:
This creates loose-looking coupling that is actually very tight. The dependencies are hidden in data conventions rather than function contracts.
A clearer design usually passes explicit values, uses well-defined state owners, or exposes deliberate APIs around shared state boundaries.
A function that appears ordinary but updates global state internally is much harder to reason about than its surface suggests.
Weak:
1(def audit-log (atom []))
2
3(defn approve-order [order]
4 (swap! audit-log conj {:event :approved :id (:id order)})
5 (assoc order :status :approved))
This mixes pure transformation with hidden mutation. That complicates:
Often the better split is:
Sometimes global state is introduced mainly because explicit parameter passing feels verbose. That is understandable, but it often trades small call-site inconvenience for much larger long-term design cost.
In Clojure, passing data explicitly is usually not boilerplate. It is one of the main ways the code stays honest about what it depends on.
If too many parameters are genuinely awkward, the problem may be:
The anti-pattern is using global state to dodge those design questions.
Global state often shows up as test pain:
When test setup feels like state detox, the codebase is telling you something. The issue is often not the test framework. It is the application’s dependence on shared mutable context.
flowchart TD
A["Input data"] --> B["Pure transformation"]
B --> C["Explicit effect boundary"]
C --> D["State owner or integration layer"]
This model keeps most reasoning local. Functions stay about values. State changes happen in places that clearly own them. Dependencies become visible again.
This anti-pattern is not a claim that global state is always forbidden.
Some shared state is reasonable:
The important distinction is whether the state is:
versus casually used as a convenience channel for everything.