Turn your functions into pure state machines.
biff.fx lets you split up a regular effectful function into a set of pure "state
functions" where effects happen in the transitions between states. This allows
you to unit test 100% of your application logic with plain (is (= (f x) y))
tests. In my opinion, it also makes your code more readable (e.g. it's easier to
skim the code and see what/where the effects are) albeit slightly more verbose.
Functions that are purified with biff.fx don't look any different to callers; the pure state functions are still wrapped by a single effectful function. So it's easy to introduce biff.fx gradually to your codebase and see if you like it. If you want to use biff.fx in a library, consumers don't need to know you're using it.
com.biffweb/fx {:mvn/version "2.0.0-rc26"}This library will be a release candidate until all the other Biff 2 libraries have been released. Until then there could be breaking changes, but I don't anticipate any.
This code snippet shows how to write a function that reads a file containing a number, increments that number, and writes the new number back to the file, returning the new number.
(require '[com.biffweb.fx :as biff.fx :refer [defmachine]])
(require '[clojure.java.io :as io])
(def handlers
{:example.fx/slurp (fn [_ctx path]
(let [file (io/file path)]
(when (.exists file)
(slurp file))))
:example.fx/spit (fn [_ctx path content]
(spit path content))})
(def ctx
{:biff.fx/handlers handlers})
(defmachine increment-file
:start
(fn [{:keys [path]}]
{:content [:example.fx/slurp path]
:biff.fx/next :increment})
:increment
(fn [{:keys [path]} {:keys [content]}]
(let [n (or (some-> content parse-long) 0)
new-n (inc n)]
{:_ [:example.fx/spit path (str new-n)]
:biff.fx/return new-n})))
(increment-file (merge ctx {:path "number.txt"}))
=> 1
(increment-file (merge ctx {:path "number.txt"}))
=> 2Now you can write simple (is (= (f x) y)) unit tests:
(require '[clojure.test :refer [deftest is]])
(deftest increment-file-tests
(let [{:keys [start increment]} (increment-file)]
(is (= (start {:path "number.txt"})
{:content [:example.fx/slurp "number.txt"],
:biff.fx/next :increment}))
(is (= (increment {:path "number.txt"} {:content "2"})
{:_ [:example.fx/spit "number.txt" "3"],
:biff.fx/return 3}))))
For comparison, here's what increment-file would look like without the biff.fx
treatment:
(defn safe-slurp [path]
(let [file (io/file path)]
(when (.exists file)
(slurp file))))
(defn increment-file [{:keys [path]}]
(let [content (safe-slurp path)
n (or (some-> content parse-long) 0)
new-n (inc n)]
(spit path (str new-n))
new-n))Machine: a function defined with biff.fx/defmachine or biff.fx/machine,
such as increment-file from the example.
Pipeline: a machine defined with biff.fx/defpipeline or biff.fx/pipeline
that transitions through a sequence of unnamed states.
Effect handlers and effect keywords: functions that perform effects and
their associated keywords, such as :example.fx/slurp (fn ...) from the
example.
State functions and state keywords: pure functions that contain your
application logic and their associated keywords, such as :increment (fn ...)
from the example.
Effect descriptor: a vector that describes an effect handler invokation,
such as [:example.fx/slurp path] from the example. The first element is an
effect keyword and the remaining elements are positional arguments for the
effect handler.
Output map: a map returned by a state function.
First, pick a function from your application you'd like to purify (i.e. turn into a machine function), such as a POST request handler. Then, define a map containing all the effect handlers that function needs to perform, such as http requests, database queries/transactions, etc. These functions should be as simple as possible: take some input, execute an effect, return the output.
(def handlers
{:example.fx/http (fn [_ctx request]
(http/request request))
...})You'll need to pass this handlers map to your machine function(s) under the
:biff.fx/handlers key. A convenient way to do that is to insert that key into
incoming Ring requests, and then your Ring handlers can be defined with
defmachine.
(def fx-handlers ...)
(defn wrap-fx-handlers [handler]
(fn [request]
(handler (merge request {:biff.fx/handlers fx-handlers}))))
(defmachine my-ring-handler
:start
(fn [request]
...)
:response
(fn [request]
{:status 200,
...}))
(def routes
["" {:middleware [wrap-fx-handlers]}
["/do-something" {:post my-ring-handler}]])Each machine function defines its own set of states, which must include at least
a :start state since that runs first. State functions typically return maps.
When you need to perform an effect, you can set one of the top-level keys in
that output map to an effect descriptor and set the :biff.fx/next key to a
state keyword. biff.fx will replace effect descriptors with the return values of
the effect handlers they reference, and then biff.fx will pass that updated
output map to the next state function.
:start
(fn [ctx]
{:result [:example.fx/do-something 1 2 3]
:biff.fx/next :process-result})
:process-result
(fn [ctx {:keys [result]}]
...)If you don't set :biff.fx/next, then after performing effects, the output map
will be used as the machine function's return value.
When a machine's states are always called sequentially, you can define them as a
pipeline to reduce boilerplate. Each function receives ctx and the previous
function's result:
(defpipeline get-user-id
(fn [_ctx email]
[:biff.sqlite.fx/execute
{:select [:user/id]
:from :user
:where [:= :user/email email]}])
(fn [_ctx result]
(-> result first :user/id)))Use :biff.fx/return to exit early.
A map containing :biff.fx/return exits early. pipeline and defpipeline
accept functions as varargs or as a single sequence.
Call a machine with no arguments to get its state-to-function map, or call a pipeline with no arguments to get its vector of state functions. You can then call the functions directly without evaluating effects.
(let [{:keys [start]} (my-machine)]
(start ctx))
=> {...}
(let [[first-state second-state] (my-pipeline)]
(first-state ctx ...)
(second-state ctx ...))Machines and pipelines can both be defined with an "initial effect descriptor" which is evaluated before the first state function runs:
(defmachine my-machine
[:example.fx/query ...]
:start
(fn [ctx query-result]
...))-
Use
:biff.fx/returnwhen you need to evaluate effects and then return a non-map value. If you only need to evaluate one effect and you want to return its value, you can return a standalone effect descriptor:(fn [ctx input] [:example.fx/do-something ...]). -
If there are multiple effect descriptors in an output map, their order of execution is not specified. If you need to execute effects in a particular order, the recommended approach is to have your effect handler accept a sequence of inputs and return a sequence of outputs. If that doesn't work (e.g. you need to perform two different kinds of effects in a particular order), set
:biff.fx/seqto a sequence of effect descriptors and output maps. They are processed in order. The sequence's maps are merged from left to right, followed by the enclosing output map, whose keys take precedence. -
In some situations you may not need more than a single
:startstate, e.g. a POST request handler that writes a value to the database and returns a 200 response unconditionally. -
If you don't need to use the return value of an effect handler, you can set the effect descriptor on an underscore-prefixed key like
:_or:_response. It will be omitted from the machine return value and from the input passed to subsequent states. -
machineanddefmachinecan both take a singlestate->fnmap instead of key-value var args. This can be useful for defining multiple machines with similar logic since you can e.g. use a helper function that returns thestate->fnmap:
(defn make-machine [{:keys [message]}]
{:start ...
:process ...})
(defmachine hello
(make-machine {:message "hello"}))
(defmachine goodbye
(make-machine {:message "goodbye"}))