Date: 2026-06-17
Source: Analysis of biff.fx by Jacob O'Bryant, applied to the Metabase codebase
Artifacts: biff.fx source (~150 LOC)
biff.fx turns effectful functions into pure state machines:
state → return data describing effects → orchestrator runs effects → next state
Each "state function" is pure — it takes a context map and returns a map with effect descriptors (e.g. [:db/query "SELECT ..."]) and a :biff.fx/next key naming the next state. An orchestrator replaces descriptors with effect-handler results, then calls the next state function.
What this buys:
- Unit-test business logic with
(is (= (fn ctx :state) expected-map))— zero mocking, zero DB - Explicit effect contracts — the return map is the documentation
- Deterministic randomness/time via injected
:biff.fx/seedand:biff.fx/now - Swappable effect handlers — replace
:db/querywith a stub in tests - Readable multi-step control flow — named states, explicit transitions
What it costs: ~2× verbosity, indirection, new abstraction to learn.
Four investigators mapped the codebase. Here's the proximity to the biff.fx ideal:
| Stage | Type | Count | Pattern |
|---|---|---|---|
| Setup | Wiring | 5 middleware | Resolve DB, bind driver, metadata provider |
| Preprocess | Pure query → query |
40 middleware | Normalize, resolve joins/fields, substitute params, apply sandboxing |
| Compile | Pure MBQL → native |
1 dispatch | driver/mbql->native — no IO |
| Execute middleware | Mixed | 10 layers | Permissions check (DB read), cache check |
| Execute | Effect boundary | 1 dispatch | driver/execute-reducible-query — opens connection, streams rows |
| Postprocess | Pure rff → rff |
15 wrappers | Format rows, limit, annotate, remap — no IO |
| Around-middleware | Effects inline | 3 layers | Save QueryExecution, view logging, catch exceptions |
Assessment: The pure data transformation core (preprocess, compile, postprocess) is excellent and biff.fx-compatible. The gap: cross-cutting effects (QueryExecution save, view logging, caching) are performed inline inside reducing functions rather than being returned as declarative effect data.
metabase.actions already has handle-effects!* — a multimethod dispatching by effect type keyword (:effect/row.modified). perform-action!* dispatches by [driver action-kw]. The perform-action-v2! function is a multi-step pipeline (validate → permission → execute → handle-effects).
Assessment: The effect dispatch mechanism exists but is vertically scoped to writeback actions only. The pipeline is one big function, not decomposed into pure state functions.
Every POST/PUT/DELETE handler interleaves effects inline:
validate params (Malli)
check permissions (api/read-check — DB read)
build data map
(with-transaction [recon position] [insert/update DB])
publish events (outside tx)
return response
Some factoring exists (create-collection!, update-card!, move-collection!, archive-collection!) but these helper functions still perform effects directly — they don't return effect descriptions.
Transaction scoping: Manual t2/with-transaction calls, no middleware. Events always outside transactions.
Three layers exist:
- Pure logic tests (
^:parallel) — string formatting, tree calculations, validation — fast, no DB - Model-level tests — call business-logic fns directly after
mt/with-tempDB setup andmt/with-current-user - API integration tests —
mt/user-http-requestthrough full Ring handler stack
Pure logic functions are easy to test. Anything mixing DB reads, decisions, and DB writes requires full integration setup.
The cheapest, highest-value move. Find decision/validation/transformation logic inside effectful handlers and extract it into pure, independently-testable functions.
Already happening in pockets: apply-defaults-to-collection, write-check-authority-level, validate-new-tenant-collection! are effectively pure. But they're called within create-collection! which tests against the full DB.
Top candidates:
update-card!— "should we update? what fields? conditional on current state?" logicmove-collection!— "is this move valid? what's the new location path?" logicdashboard-copy!— "what gets copied, what gets renamed?" logic
Test impact: Extracted functions get ^:parallel tests with plain data. Fast, deterministic, exhaustive branch coverage.
Instead of calling events/publish-event!, analytics/track-event!, notification/send! inline, handlers return an :effects vector that middleware processes post-response. The actions system's handle-effects!* multimethod already exists for this — extend it with :effect/event, :effect/notification, :effect/search.
;; Before — effects called inline, hard to suppress in tests
(events/publish-event! :event/collection-create {:object coll})
(notification/delete-card-notifications-and-notify! ...)
(search/bulk-ingest! ...)
;; After — handler returns effects as data
{:result coll
:effects [[:event :collection-create {:object coll}]
[:notification :delete-card-notifications {:cards ...}]
[:search :bulk-ingest {:ids card-ids}]]}Wire middleware around defendpoint to process :effects after the handler returns. Test impact: Mock the effect middleware once, then all handlers are effect-free in tests.
For the Stripe-checkout-class problems — operations where logic and DB calls genuinely interleave — adopt the state machine pattern. Not a library; a simple loop/recur runner is ~30 lines of Clojure.
The only real candidates are the 5–10 most complex handlers where:
- 3+ steps interleave DB reads, decisions, and DB writes
- Current tests are thin (integration-only, hard to cover branches)
- Logic changes frequently enough to justify the verbosity cost
Concrete example — dashboard-copy! (simplified, ~7 state functions):
(defn copy-dashboard-fn
:start (fn [ctx] {:orig-id ctx, :biff.fx/next :read-original})
:read-original (fn [ctx] {:_ [:db/query-one :model/Dashboard (:orig-id ctx)]
:biff.fx/next :check-read-perms})
:check-read-perms (fn [ctx] (api/read-check (:_ ctx))
{:biff.fx/next :insert-copy})
:insert-copy (fn [ctx] {:_ [:db/insert! :model/Dashboard (build-copy-body ctx)]
:biff.fx/next :copy-cards})
:copy-cards (fn [ctx] {:_ [:db/query :model/DashboardCard {:dashboard_id (:id (:orig ctx))}]
:biff.fx/next :insert-cards})
:insert-cards (fn [ctx] {:_ [:db/insert-many! :model/DashboardCard
(map #(dup-card % (:copy ctx)) (:cards ctx))]
:biff.fx/next :copy-tabs})
:done (fn [ctx] {:biff.fx/return (:copy ctx)}))Each state function is pure. Test with:
(is (= (copy-dashboard-fn ctx :insert-cards) {:expected-map ...}))The runner — a simple loop over state transitions — handles DB calls and transition routing.
Cost: ~2–3× more code than the inline version. Only worth it where testability + clarity payoff exceeds verbosity cost.
- Build a general effect system for all API handlers. For simple CRUD endpoints, the sandwich pattern works: validate → DB write → events. The state machine adds indirection without value.
- Rewrite the QP pipeline. It works. The pure transformation stages are excellent. Step 2 (effect descriptors) would clean up cross-cutting inline effects without restructuring the pipeline.
- Add a library dependency. biff.fx is ~150 lines of Clojure. The pattern, not the library, is what matters.
Pick one complex handler — dashboard-copy! or update-card! — and refactor into explicit state functions with a simple runner. Ship it. Measure:
- Test coverage: Can every branch be tested without DB setup?
- Review velocity: Does the state machine make the flow easier to review?
- Bug prevention: Do pure tests catch regressions that integration tests miss?
If it pays off on all three, expand to the other top-5 complex handlers. If not, the extracted pure functions (Step 1) still carry their weight, and the knowledge was worth one refactor.
- API Handlers — inline effect interleaving patterns across dashboards, cards, collections, and agent APIs
- Actions System — existing
handle-effects!*multimethod andperform-action-v2!pipeline - QP Pipeline — 40+40+15 middleware stages, pure-vs-effectful classification
- Testing Patterns — three-layer testing pyramid, where pure testing is easy vs. hard