As Clojure developers we love the simplicity of putting almost all application state under a single atom. You get isolation (as long as you don't deref
twice) thanks to immutable (persistent) collections and transactional changes thanks to swap!
combined with functional composition.
In this article I expose a pattern I name the "transactor pattern" because it borrows a lot from Datomic's transaction functions.
We've applied it both in the large (whole application state) and in the small (some hairy scheduling and planning algorithm).
When applied to application state, it may seem at odds with re-frame/re-dash but it's more an overlap than a conflict as we'll see.
Baptiste and I have some free capacity in January and are looking for new gigs: from letting you pick our brains for a couple of hours, to handling a whole project or writing a prototype or a MVP. Get in touch!
Else we are working on ClojureDart or on our app Paktol (The positive spending tracker where money goes up!)—we are dutifully dogfooding an upcoming big feature at the moment.
In the beginning updates are mere assoc
on a couple of keys but as the state grows in complexity, more invariants get defined. You may add validators to enforce invariants but still it's a constant burden spread across the codebase. You start to feel the need to encapsulate.
business.logic.belongs.here
namespaceNow you define a new namespace made of "blessed" core functions to update the state and maintain invariants. Only these functions should ever be used in swap!
.
Congrats, you have a stratified design but it's easy to bypass. There are actually two ways to bypass such a stratified design:
swap!
without using one function from the business.logic.belongs.here
namespace,business.logic.belongs.here
namespace just balloons.The obvious way is generally tackled by adding the swap!
in favor of an exec!
or dispatch!
which takes a command (or event) which will be resolved to a handler.
However this often encourages the number of handlers to grow thus the second point is worsened.
Plus when swap!
was still allowed it was easy to compose a function out of functions of the business.logic.belongs.here
namespace.
This pattern is about fixing the composability and, by doing so, preventing the ever-ballooning namespace.
It relies on two things:
:db/add
and custom Datomic transaction functions. It's also not unlike the difference between special forms and macros — "macro command pattern" could be another name. The set of core commands should evolve slowly and deliberately.Here is a minimal implementation:
(defn tx [state-value cmd]
(if (fn? cmd)
; recursive expansion
(reduce tx state-value (cmd state-value))
; core functions
(exec state-value cmd)))
(defn tx! [*state msg]
(swap! *state tx msg))
Transactions/compound commands/macro commands are represented by functions. Functions which receive the current state value as input but return a (possibly empty) list of commands to perform in order on this state.
Thus a custom command has full read-only access to the state without having uncontrolled write access.
Encapsulation without abstraction.
Instead of tx
which only computes the final state value you can choose to look at the expansion too:
(defn tx-expand
"Returns a vector whose last value is the updated state-value and the rest of the of vector is the command expansion."
[state-value cmd]
(if (fn? cmd)
; recursive expansion
(reduce (fn [stack cmd]
(let [state-value (peek stack)]
(into (pop stack) (tx-expand state-value cmd))))
[state-value]
(cmd state-value))
; core functions
[cmd (exec state-value cmd)]))
An extremely artificial example: the Collatz transaction!
(defn exec [x op]
; generally its rather [x [op & args]]
; or [x {:keys [op] :as cmd}]
; but here we have no args 🤷♂️
(case op
:3n+1 (inc (* 3 x))
:halve (quot x 2)))
(defn collatz [n]
; a recursive transaction
(cond
(<= n 1) nil
(even? n) [:halve collatz]
:else [:3n+1 collatz]))
=> (tx-expand 7 collatz)
[:3n+1 :halve :3n+1 :halve :3n+1 :halve :halve :3n+1 :halve :halve :halve :3n+1 :halve :halve :halve :halve 1]
Having a command being expandable allows for effective intercession. When you only have state values you have excellent introspection but you have almost no way to understand the way state was changed: you can only try to reconstruct semantics from a diff.
Being able to reflect upon the changes to apply and possibly modify them ("change the changes") opens possibilities in terms of logging, access control, sync and even undo/redo support — snapshot-based undo/redo works as long as your state is only yours, has a clear separation between document state and UI state and can't be affected by IO — and as French philosopher Jean-Paul Sartre famously said: "IO is Other People".
When you want to do any kind of side effect with this pattern, the simplest way is to have an :io!
core command. During command expansion, any :io! commands found are queued and executed after the transaction is successfully run. This mirrors the interaction between agents and STM in Clojure.
I recommend designating part of your state as a "scratch pad," perhaps under a :scratch or :unsafe key. Use an :unsafe-assoc-in
core command, scoped specifically to this key, to avoid inflating your core commands with one-off cases. This has the added benefit of highlighting areas that may need more careful design in the future.
The transactor pattern feels to me like a natural fit for Clojure, reflecting the principles already present in the language and its ecosystem.
By promoting composability and minimizing the number of core operations, this approach simplifies reasoning about the system. We can focus solely on the core commands and ignore the quirks of individual handlers.