An effect dispatch library for Clojure with schema-driven discoverability.
Sandestin provides a structured way to dispatch side effects while maintaining excellent introspection capabilities. It's designed to work seamlessly with LLM-assisted workflows and REPL-driven development.
- Effect dispatch - Dispatch vector-based effects with composable registries
- Actions - Pure functions that expand into effect sequences
- Placeholders - Late-bound value resolution from dispatch context
- Interceptors - Lifecycle hooks for instrumentation and control flow
- Schema-driven - Malli schemas for all registered items
- Discoverability - Built-in functions to describe, sample, search, and inspect
Add to your deps.edn:
{:deps
{io.github.brianium/sandestin {:git/tag "v0.6.0" :git/sha "5888508"}}}(ns myapp.core
(:require [ascolais.sandestin :as s]))
;; Define a registry with effects
(def my-registry
{::s/effects
{:myapp/log
{::s/description "Log a message"
::s/schema [:tuple [:= :myapp/log] :string]
::s/handler (fn [_ctx _system msg]
(println msg)
:logged)}}})
;; Create a dispatch function
(def dispatch (s/create-dispatch [my-registry]))
;; Dispatch effects
(dispatch {} {} [[:myapp/log "Hello, Sandestin!"]])
;; => {:results [{:effect [:myapp/log "Hello, Sandestin!"], :res :logged}]
;; :errors []}Effects are side-effecting operations. Each effect has a handler that receives:
ctx- Context map with:dispatch,:dispatch-data,:systemsystem- The live system map (database connections, config, etc.)& args- Additional arguments from the effect vector
{::s/effects
{:db/execute
{::s/description "Execute a SQL query"
::s/schema [:tuple [:= :db/execute] :string [:* :any]]
::s/system-keys [:datasource]
::s/handler (fn [{:keys [dispatch]} system sql & params]
(let [result (jdbc/execute! (:datasource system)
(into [sql] params))]
;; Optionally dispatch continuation effects
(dispatch {:result result} [[::log "Query complete"]])
result))}}}The dispatch function in handler context supports three arities:
(dispatch fx)— dispatch with current system and dispatch-data(dispatch extra-dispatch-data fx)— merge additional dispatch-data(dispatch system-override extra-dispatch-data fx)— merge into both system and dispatch-data
The 3-arity form enables effects to dispatch nested effects with a modified system context:
;; Route nested effects to a different connection
(fn [{:keys [dispatch]} system connection-key child-fx]
(when-some [alt-conn (get-connection (:pool system) connection-key)]
(dispatch {:sse alt-conn} ; merged into system
{} ; extra dispatch-data
child-fx)))Actions are pure functions that transform state into effect vectors. They receive immutable state (extracted via ::system->state) and return effects.
{::s/actions
{:myapp/greet-user
{::s/description "Greet a user and log the event"
::s/schema [:tuple [:= :myapp/greet-user] :string]
::s/handler (fn [state username]
[[:myapp/log (str "Hello, " username "!")]
[:myapp/save-greeting {:user username :at (System/currentTimeMillis)}]])}}
::s/system->state
(fn [system] (:app-state system))}Placeholders resolve values from dispatch-data at dispatch time. They enable late binding of values, particularly useful for async continuations.
{::s/placeholders
{:myapp/current-user
{::s/description "Get current user from dispatch context"
::s/schema :map
::s/handler (fn [dispatch-data]
(:current-user dispatch-data))}}
::s/effects
{:myapp/greet
{::s/handler (fn [_ctx _sys user]
(str "Hello, " (:name user) "!"))}}}
;; Usage with placeholder
(dispatch {} {:current-user {:name "Alice"}}
[[:myapp/greet [:myapp/current-user]]])Interceptors provide lifecycle hooks around dispatch, action expansion, and effect execution.
(def logging-interceptor
{:id ::logging
:before-dispatch (fn [ctx] (tap> {:event :dispatch-start}) ctx)
:after-dispatch (fn [ctx] (tap> {:event :dispatch-end :errors (:errors ctx)}) ctx)
:before-effect (fn [ctx] (tap> {:event :effect :effect (:effect ctx)}) ctx)})
{::s/interceptors [logging-interceptor]}Built-in interceptors:
ascolais.sandestin.interceptors/fail-fast- Stop on first error
Sandestin is designed for LLM-assisted development. Use these functions to explore registered items:
;; All items
(s/describe dispatch)
;; By type
(s/describe dispatch :effects)
(s/describe dispatch :actions)
(s/describe dispatch :placeholders)
;; Specific item
(s/describe dispatch :db/execute)
;; => {:ascolais.sandestin/key :db/execute
;; :ascolais.sandestin/type :effect
;; :ascolais.sandestin/description "Execute a SQL query"
;; :ascolais.sandestin/schema [:tuple ...]
;; :ascolais.sandestin/system-keys [:datasource]}Generate sample data using Malli generators:
(s/sample dispatch :db/execute)
;; => [:db/execute "generated-string" 42]
(s/sample dispatch :db/execute 3)
;; => ([:db/execute ...] [:db/execute ...] [:db/execute ...])Search all metadata by pattern. Grep performs a deep search across:
- Effect/action/placeholder keys and descriptions
- Malli schema
:descriptionproperties (parameter documentation) - All library-provided metadata (e.g.,
::phandaal/returns, custom keys)
(s/grep dispatch "database")
;; => ({:ascolais.sandestin/key :db/execute ...})
(s/grep dispatch #"log|save")
;; => items matching the regex
;; Finds effects with "user" in parameter descriptions
(s/grep dispatch "user")Get all schemas as a map:
(s/schemas dispatch)
;; => {:db/execute [:tuple ...], :myapp/log [:tuple ...], ...}Get merged system requirements:
(s/system-schema dispatch)
;; => {:datasource [...schema...], :config [...schema...]}A registry is a map with these keys (all namespaced under ascolais.sandestin):
{::s/effects {qualified-keyword -> EffectRegistration}
::s/actions {qualified-keyword -> ActionRegistration}
::s/placeholders {qualified-keyword -> PlaceholderRegistration}
::s/interceptors [Interceptor ...]
::s/system-schema {keyword -> MalliSchema}
::s/system->state (fn [system] state)};; Effect
{::s/description "Human-readable description"
::s/schema [:tuple [:= :effect/key] ...args...]
::s/handler (fn [ctx system & args] result)
::s/system-keys [:datasource :config]} ; optional
;; Action
{::s/description "..."
::s/schema [:tuple [:= :action/key] ...args...]
::s/handler (fn [state & args] [[effects...]])}
;; Placeholder
{::s/description "..."
::s/schema MalliSchema ; schema for the resolved value
::s/handler (fn [dispatch-data & args] value)}
;; Interceptor
{:id :qualified/keyword
:before-dispatch (fn [ctx] ctx)
:after-dispatch (fn [ctx] ctx)
:before-action (fn [ctx] ctx)
:after-action (fn [ctx] ctx)
:before-effect (fn [ctx] ctx)
:after-effect (fn [ctx] ctx)}Registries can be composed from multiple sources:
(def dispatch
(s/create-dispatch
[[db/registry {:dbtype "postgresql"}] ; vector [fn & args]
auth/registry ; zero-arity fn
{:myapp/effects {...}}])) ; plain mapMerge rules:
- Effects, actions, placeholders: later wins on conflict (with tap> warning)
- Interceptors: concatenated in order
- system-schema: merged (later wins per key)
- system->state: last wins
- Run before-dispatch interceptors
- Interpolate placeholders in input
- Expand actions to effects (with before/after-action interceptors)
- Execute effects (with before/after-effect interceptors)
- Run after-dispatch interceptors
- Return
{:results [...] :errors [...]}
clj -M:dev(dev) ; Switch to dev namespace
(start) ; Start system (opens Portal)
(reload) ; Reload changed namespaces
(restart) ; Full restartclj -X:testSandestin ships with Claude Code skills for LLM-assisted development:
/fx-explore- Discover available effects, actions, and placeholders via REPL/fx-registry- Create new registries following project conventions
Install by copying to your skills directory:
cp -r .claude/skills/fx-explore ~/.claude/skills/
cp -r .claude/skills/fx-registry ~/.claude/skills/Copyright 2025 Brian Scaturro
Distributed under the Eclipse Public License version 1.0.