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 eventsuccess-fn
A predicate function that determines the success ofbody
.events
A Clojure map with events as keys and predicates as values.allow-undeclared-events?
When Benjamin receives an event not found in theevents
map, it will either allowsbody
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).
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 [db :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.