Benjamin
Idempotency with side-effects

Table of Contents

Description

Benjamin gives you a macro that transforms code like this:

(let [logbook (get-logbook entity)]
  (when (some pred logbook)
    (let [response (body)]
      (when (success? response)
        (write logbook)))))

Into this:

(with-logbook entity :event
  body)

There is a blog post that delves in the motivation and backstory.

Usage

In your namespace, require:

[benjamin.core :refer [with-logbook]]
(with-logbook user :newsletter
  (email (:email user) newsletter))  

Benjamin executes body in a future and returns that future object immediately. Deref'ing the latter is subject to the semantics of futures (blocks until operations complete). success-fn is provided to determine the success of body. success-fn runs within the same thread as body and will not block.

Configuration

  • logbook-fn A function that retrieves a logbook given an entity.
  • persistence-fn A function that persists an updated logbook given an entity and an event
  • success-fn A predicate function that determines the success of body.
  • events A Clojure map with events as keys and predicates as values.
  • allow-undeclared-events? When Benjamin receives an event not found in the events map, it will either allows body to run or forbid it. This boolean setting determines this behavior. Defaults to false.

Tip: system users can configure this library via a component that ships with the latest snapshot.

Manual configuration is done by requiring:

[benjamin.configuration :refer [set-config!]]

Accessing the logbook

(set-config! :logbook-fn f)

logbook-fn is a function that receives the entity as argument and returns a logbook. The default is :logbook which will work when the entity map embeds the logbook, as in:

{:first-name "Benjamin"
 :last-name "Peirce"
 :occupation "Mathematician"
 :email "benjamin.peirce@harvard.edu"
 :logbook [{:welcome-email timestamp}
           {:subscription-reminder timestamp}
           {:subscription-reminder timestamp}
           {:newsletter timestamp}
           {:newsletter timestamp}
           {:newsletter timestamp}]}

Deriving predicates from events

(set-config! :events {:event predicate
                      :event predicate
                      :event predicate
                      ...})

Predicates are one argument functions that receive a logbook entry. A logbook entry is a map with an event as the key and a timestamp as the value.

The following example checks if the logbook entry was written today.

#(if-let [date (first (vals %))]
   (time/today? date)
   false)

Several predicates are offered in the benjamin.predicates namespace for convenience. That namespace has a dependency that you need to require in your build should you want to use them. This is because benjamin does not have any dependency of its own (it relies entirely on language features).

Sorry, your browser does not support SVG.

Getting the logbook

:logbook-fn is a function of one argument, entity. Its responsibility is to retrieve the logbook for a entity (user, for example). The return value is a vector of maps, where the map has the event as key, and a date as value. Benjamin will run the predicate associated with the event on the logbook to determine whether the side-effect should is allowed or not.

Tip: If you have dependencies (for example, a database), use a higher–order function that returns logbook-fn. Tip: The benjamin component in the system library has an option called logbook-fn-wrap-component? that is meant to achieve this.

Writing to the logbook

:persistence-fn is a function of two arguments, entity and event. Its responsibility is to append to the logbook and persist the entity. You have to provide an implementation or an error will be thrown. For example:

(set-config! :persistence-fn
             (fn [entity event] (let [logbook (conj (:logbook entity) {event (t/now)})]
                                 (assoc entity :logbook logbook)
                                 (save db entity))))

Tip: If you have dependencies (for example, a database), use a higher–order function that returns persistence-fn.

(defn logbook [{{store :store} :db :as dependencies}]
  (fn [entity event] (let [logbook (conj (:logbook entity) {event (t/now)})]
                       (assoc entity :logbook logbook)
                       (save db entity)))

Tip: The benjamin component in the system library includes an option, persistence-fn-wrap-component?, that will wrap dependencies associated with it in the system map.

Status of the side-effect

The success function is a function of one argument, ie. the return value of the side-effectful body. It determines if the operation was successful and thus for inclusion in the logbook.

(set-config! :success-fn (constantly true))

The default assumes all your operations will be A-okay. You’ll probably want to pass along something more realistic.

Policy with regard to unknown events

(with-logbook entity event
  body)

If the event is unknown, that is if it doesn’t show up in the events map, no predicate can be derived and then we rely on a policy you can set yourself. Either we accept unknown events and we proceed with the side-effect, or we reject them and return immediately. The default is strict, but you can change that.

(set-config! :allow-undeclared-events? true)

Tests

A test suite is provided in benjamin.core-test. Call (test-ns *ns*) in the namespace, or run boot testing for continous testing.

Limitations

You can work with as many entities you want. You can declare as many events as you want. You can have any side-effectful procedures in the body. Your success-fn may dispatch on the return value if you run different types of operations in the body.

The configuration is a singleton with dynamic scope, so deal with it to the best of your understanding. Personally, I set it once and treat it as a constant for the lifetime of the application.

License

Licensing terms will be revealed shortly. In the meantime, do what you want with it.

Author: Daniel Szmulewicz

Created: 2020-08-03 Mon 14:38

Validate