~ / track D / clojure in practice
Organizing application state
IntermediatepracticeEvery program eventually needs state — a thing that changes over time. Clojure's answer is to keep most of the code pure and concentrate mutability in a small number of named reference cells (atoms, refs, agents). The question this page is about isn't which primitive — that's covered in state atoms, refs, and agents — it's the practitioner's question: how many state cells should my application have, and what should each one hold? When the authoritative history lives in an append-only log, see event sourcing for how current state becomes a derived view.
The two extremes
At one extreme: one atom for the whole app. Re-frame and similar front-end frameworks popularized this. A single map under a single atom holds everything; every read pulls from it, every change swap!clojure.core/swap!Atomically apply f to the atom's value.view on clojuredocs →s it. The appeal is simplicity — there's exactly one place to look, and the entire state is one printable value.
At the other extreme: one atom per concept. A users atom, a sessions
atom, a cache atom, a metrics atom. Each cell is small, each swap!clojure.core/swap!Atomically apply f to the atom's value.view on clojuredocs →
touches one concern, and concurrent writers don't fight over an unrelated
key.
Both work. The decision rests on three questions.
1. Do these pieces of state change together?
The biggest mistake with multiple atoms is using two of them where you needed one. If updating "user" and "session" must always succeed or fail together, splitting them across two atoms is a bug waiting to be observed mid-transaction by a reader.
When two cells must change together, one atom is the right answer. Put both keys under a single map and swap!clojure.core/swap!Atomically apply f to the atom's value.view on clojuredocs → once:
The reverse mistake is more common: putting unrelated state in the same atom out of habit. If two keys never appear in the same swap!clojure.core/swap!Atomically apply f to the atom's value.view on clojuredocs →, splitting them is free — and concurrent writers stop colliding.
2. Who needs to see consistent reads?
@some-atom returns a snapshot. If a UI render reads three different
atoms, the three snapshots can be from three different moments. For a
dashboard that's fine; for a financial report it's wrong. When consistency
between cells matters, the cells must share a transaction boundary — either
the same atom, or a set of refs under dosyncclojure.core/dosyncRun a transaction; ref updates inside are coordinated and retry on conflict.view on clojuredocs →.
The rough rule: small read-mostly state is happy in atoms; coordinated state changes (transferring funds between accounts) want refs.
3. What's the contention shape?
An atom that everyone writes to becomes a contention hotspot. CAS retries
multiply, throughput drops. If your metrics counter is bumped on every
request, that's a hot atom — split it (one atom per metric name) or use
agents (which serialize writes asynchronously).
A useful frame: an atom is fast as long as most attempts succeed on the first try. Many concurrent writers updating the same atom guarantees they won't.
A practical layout
Most server-side Clojure apps end up with a layout like this:
{:config config-map ;; immutable, set once at boot
:db {:pool …} ;; opaque handle, no swap!
:cache (atom {}) ;; hot, but per-key, low contention
:metrics (atom {}) ;; small map, summed at end of request
:rate-limiter (atom {}) ;; per-key counters
:runtime (atom {:started-at … }) ;; misc world facts}A few habits worth borrowing:
- Config is not state. Put it in a plain map. The fact that it's read often doesn't make it mutable.
- Connection pools and queues aren't state either. The library that hands them out manages their lifecycle; you just hold the handle.
- Hot writers get their own cell, or get split by key.
- Read paths should be cheap.
@some-atomis one dereference; multiple@s in a render loop is the smell that the state is over-split.
Watches and where they belong
add-watch lets you react to changes:
Watches are useful for cross-cutting concerns (audit logs, push to a subscribing UI, metrics emission) where the cause of the change shouldn't have to know about every effect. But they run synchronously on the swapper's thread, so a slow watch slows down every write. Keep them short — push to a queue, then return.
In a Clojure service
A typical Ring service holds immutable handles (:db, :config) in the
system map and a handful of atoms for runtime facts. Use one atom when
subsystems must change together — cache entries and their TTL metadata, or
rate-limiter counters that must reset atomically. Use per-subsystem atoms
when writers are independent: a hot metrics counter bumped on every
request should not contend with a cold feature-flags map. The functional
core never dereferences atoms; only the imperative shell reads and swaps them
at request boundaries.
(def runtime (atom {:cache {} :rate-limiter {}})) ;; coordinated writes
(def metrics (atom {:requests 0 :errors 0})) ;; hot, isolated
(defn bump-metric! [kind]
(swap! metrics update kind (fnil inc 0)))
(defn with-cache [db k f]
(or (get-in @runtime [:cache k])
(let [v (f db)]
(swap! runtime assoc-in [:cache k] v)
v)))Check yourself
? quiz
You have two atoms, `:users` and `:sessions`, and a function that updates both on user creation. A teammate notices the UI sometimes shows a user with no session. What's the underlying problem?
Exercise
Sketch the state layout for a small chat server. It needs to track:
connected users (id → display-name), per-user unread counts, and a global
"messages-per-second" counter. Decide how many atoms to use and why; then
write a connect! function that adds a user and initializes their unread
count in a single atomic write.