Dec. 9, 2024, 2:35 p.m.

Beyond `swap!`: Encapsulation sans Abstraction, the Transactor Pattern

Curiosities by Tensegritics

by cgrand (🦋 🦣 𝕏)

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.

Sartre.png

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 was the atom

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.

Then comes the business.logic.belongs.here namespace

Now 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:

  • the obvious one where you call swap! without using one function from the business.logic.belongs.here namespace,
  • the sneaky one where the 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.

The transactor pattern

This pattern is about fixing the composability and, by doing so, preventing the ever-ballooning namespace.

It relies on two things:

  • having a clear separation between core commands and compound commands. Like between :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.
  • compound commands expanding to core commands — the command is never going to effect the change itself. Just return commands which will get, in turn, expanded until everything bottoms out to core commands.

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.

Dry runs and expansion

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]

Intercession: the oft forgotten half of reflection

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".

What about IO?

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.

Scratching an itch

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.

Conclusion

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.

You just read issue #13 of Curiosities by Tensegritics. You can also browse the full archives of this newsletter.

Share on Twitter Share on LinkedIn Share on Hacker News Share on Reddit Share on Mastodon
This email brought to you by Buttondown, the easiest way to start and grow your newsletter.