Skip to content

Instantly share code, notes, and snippets.

@escherize
Created June 17, 2026 22:08
Show Gist options
  • Select an option

  • Save escherize/a1fd9ed736ba79a68bad8d833aa4269c to your computer and use it in GitHub Desktop.

Select an option

Save escherize/a1fd9ed736ba79a68bad8d833aa4269c to your computer and use it in GitHub Desktop.
Toward a biff.fx-style effects architecture in Metabase — analysis and concrete steps

Toward a biff.fx-Style Effects Architecture in Metabase

Date: 2026-06-17
Source: Analysis of biff.fx by Jacob O'Bryant, applied to the Metabase codebase
Artifacts: biff.fx source (~150 LOC)


The Idea

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:

  1. Unit-test business logic with (is (= (fn ctx :state) expected-map)) — zero mocking, zero DB
  2. Explicit effect contracts — the return map is the documentation
  3. Deterministic randomness/time via injected :biff.fx/seed and :biff.fx/now
  4. Swappable effect handlers — replace :db/query with a stub in tests
  5. Readable multi-step control flow — named states, explicit transitions

What it costs: ~2× verbosity, indirection, new abstraction to learn.


Where Metabase Stands

Four investigators mapped the codebase. Here's the proximity to the biff.fx ideal:

Query Processor pipeline — closest

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.

Actions System — localized biff.fx

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.

API Handlers — farthest

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.

Testing

Three layers exist:

  1. Pure logic tests (^:parallel) — string formatting, tree calculations, validation — fast, no DB
  2. Model-level tests — call business-logic fns directly after mt/with-temp DB setup and mt/with-current-user
  3. API integration testsmt/user-http-request through full Ring handler stack

Pure logic functions are easy to test. Anything mixing DB reads, decisions, and DB writes requires full integration setup.


Three Concrete Steps (ordered by ROI)

Step 1: Extract Pure Core Functions (no infrastructure needed)

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?" logic
  • move-collection! — "is this move valid? what's the new location path?" logic
  • dashboard-copy! — "what gets copied, what gets renamed?" logic

Test impact: Extracted functions get ^:parallel tests with plain data. Fast, deterministic, exhaustive branch coverage.

Step 2: Effect Descriptors for Cross-Cutting Concerns

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.

Step 3: State Machine for Multi-Step Operations

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.


What NOT to Do

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

Recommended First Experiment

Pick one complex handler — dashboard-copy! or update-card! — and refactor into explicit state functions with a simple runner. Ship it. Measure:

  1. Test coverage: Can every branch be tested without DB setup?
  2. Review velocity: Does the state machine make the flow easier to review?
  3. 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.


Investigators' Full Reports

  • API Handlers — inline effect interleaving patterns across dashboards, cards, collections, and agent APIs
  • Actions System — existing handle-effects!* multimethod and perform-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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment