~ / track D / clojure in practice

Organizing application state

Intermediatepractice

Every 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 s‌w‌a‌p‌!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 s‌w‌a‌p‌! 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.

loading sci
press ⌘/Ctrl-↵ or click ▶ run to evaluate

When two cells must change together, one atom is the right answer. Put both keys under a single map and s‌w‌a‌p‌! once:

loading sci
press ⌘/Ctrl-↵ or click ▶ run to evaluate

The reverse mistake is more common: putting unrelated state in the same atom out of habit. If two keys never appear in the same s‌w‌a‌p‌!, 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 d‌o‌s‌y‌n‌c.

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-atom is 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:

loading sci
press ⌘/Ctrl-↵ or click ▶ run to evaluate

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.

loading sci
press ⌘/Ctrl-↵ or click ▶ run to evaluate
 status: new