Liking cljdoc? Tell your friends :D

workflow

Declarative state machine workflows for domain entities. Provides permission-based transitions, automatic audit trails, and optional side-effect dispatch via wagoe-jobs.

Key namespaces

NamespacePurpose

wagoe.workflow.schema

Malli schemas: WorkflowDefinition, WorkflowInstance, AuditEntry

wagoe.workflow.ports

Protocols: IWorkflowStore, IWorkflowEngine, IWorkflowRegistry

wagoe.workflow.core.machine

defworkflow macro, in-process definition registry

wagoe.workflow.core.transitions

Pure transition logic; available-transitions-with-status

wagoe.workflow.core.audit

Pure audit entry constructors

wagoe.workflow.shell.service

Orchestration: load → validate → persist → side-effects

wagoe.workflow.shell.persistence

DB persistence via next.jdbc + HoneySQL

wagoe.workflow.shell.http

REST API routes (start, transition, state, audit log)

Defining a workflow

(require '[wagoe.workflow.shell.registry :refer [defworkflow]])

(defworkflow order-workflow
  {:id             :order-workflow
   :initial-state  :pending
   :description    "E-commerce order lifecycle"
   :states         #{:pending :paid :shipped :delivered :cancelled}
   :state-config   {:pending   {:label "Awaiting Payment"}
                    :paid      {:label "Payment Received"}
                    :shipped   {:label "In Transit"}
                    :delivered {:label "Delivered"}
                    :cancelled {:label "Cancelled"}}
   ;; Each hook is a vector of fns of [instance audit-entry context].
   :hooks          {:on-enter-paid     [(fn [instance _audit-entry _context]
                                          (notify-finance! instance))]
                    :on-any-transition [(fn [instance _audit-entry _context]
                                          (sync-external! instance))]}
   :transitions    [{:from :pending :to :paid
                     :label "Mark as Paid"
                     :required-permissions [:finance :admin]}
                    {:from :paid    :to :shipped
                     :guard :payment-confirmed}
                    {:from :shipped :to :delivered
                     :label "Confirm Delivery"}
                    {:from :pending :to :cancelled
                     :auto?        true
                     :side-effects [:notify-cancellation]}]
   :guards         {:payment-confirmed (fn [{:keys [payment-status]}]
                                         (= :confirmed payment-status))}})

Guards

A guard takes one map and returns truthy (allow) or falsy (reject). Declare it under :guards in the workflow definition. The map holds:

  • every key of the request’s :context

  • :workflow/instance, the stored instance

  • :workflow/entity, a delay, when the workflow has an :entity-loader. Deref it to call (entity-loader entity-type entity-id); it loads at most once per check.

A caller’s own :workflow/* context keys are dropped, so a request cannot forge either. A loader that throws fails the transition with an :internal-error; available-transitions shows the guarded transitions as unavailable and logs it. This guard refuses to deliver an invoice with no lines:

(def invoice-workflow
  {:id            :invoice-workflow
   :initial-state :draft
   :states        #{:draft :delivered}
   :transitions   [{:from :draft :to :delivered :name :deliver :guard :has-lines?}]
   ;; count-invoice-lines is yours, e.g. SELECT count(*) FROM invoice_lines WHERE invoice_id = ?
   :entity-loader (fn [_entity-type invoice-id]
                    {:line-count (count-invoice-lines invoice-id)})
   :guards        {:has-lines? (fn [{:workflow/keys [entity]}]
                                 (pos? (:line-count @entity)))}})

Guards passed to create-workflow-service apply to every workflow; a workflow’s own :guards win.

Configuration

wagoe add workflow writes this under :active, and it is all the module needs:

{:wagoe/workflow {}}

The module wires its own database context and schema, and the job queue when :wagoe/jobs is enabled.

HTTP API

POST /api/v1/workflow/instances                # Start a workflow instance
GET  /api/v1/workflow/instances?entity-type=&entity-id=  # Find an entity's instances
POST /api/v1/workflow/instances/:id/transition # Perform a state transition
GET  /api/v1/workflow/instances/:id            # Get current state
GET  /api/v1/workflow/instances/:id/audit      # Get audit trail

Every route requires authentication (session or bearer token) and answers 401 without it.

Bodies are kebab-case JSON, as in a scaffolded module’s API. Start an instance with:

{"workflow-id": "order-workflow",
 "entity-type": "order",
 "entity-id":   "5b1c9a52-6f0e-4d7a-9a57-2d9e3c1b8f40"}

A transition answers {"instance": {…}, "audit-entry": {…}}. One the workflow does not make from the current state answers 422:

{"error": {"type":    "transition-not-found",
           "message": "Transition 'paid' is not allowed from state 'entered'"}}

An unknown instance is a 404 and a malformed body a 400, in the platform’s error shape.

To find an entity’s instance, look it up by the entity’s type and id:

GET /api/v1/workflow/instances?entity-type=order&entity-id=5b1c9a52-6f0e-4d7a-9a57-2d9e3c1b8f40

It answers a list, one instance per workflow the entity is in, and [] for none. Both parameters are required. GET /instances/:id adds the available transitions.

[{"id":            "0d4b7f3e-2a61-4c55-8f0e-9b7a1c2d3e4f",
  "workflow-id":   "order-workflow",
  "entity-type":   "order",
  "entity-id":     "5b1c9a52-6f0e-4d7a-9a57-2d9e3c1b8f40",
  "current-state": "entered",
  "created-at":    "2026-09-27T12:00:00Z",
  "updated-at":    "2026-09-27T12:00:00Z"}]

Available transitions

available-transitions takes the instance id, the actor’s roles and the guard context. Each transition out of the current state comes back with :enabled?, and a :reason when it is false.

(require '[wagoe.workflow.ports :as ports])

(def instance
  (ports/start-workflow! engine {:workflow-id :order-workflow
                                 :entity-type :order
                                 :entity-id   order-uuid}))

(ports/available-transitions engine (:id instance) [:admin] {})
;; => [{:id :paid :to :paid :label "Mark as Paid" :enabled? true}
;;     {:id :cancelled :to :cancelled :enabled? true}]

(ports/available-transitions engine (:id instance) [:viewer] {})
;; => [{:id :paid :to :paid :label "Mark as Paid" :enabled? false :reason :insufficient-permissions}
;;     {:id :cancelled :to :cancelled :enabled? true}]

Testing

clojure -M:test:test/pg :workflow

Can you improve this documentation? These fine people already did:
thijscreemers & Thijs Creemers
Edit on GitHub

cljdoc builds & hosts documentation for Clojure/Script libraries

Keyboard shortcuts
Ctrl+kJump to recent docs
←Move to previous article
→Move to next article
Ctrl+/Jump to the search field
× close