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, ITransactionalWorkflowStore, IWorkflowEngine, IWorkflowRegistry

wagoe.workflow.core.machine

Pure introspection of a definition: states, initial state, transitions

wagoe.workflow.shell.registry

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. The three reads and the start also need the :admin role or a :workflow/read grant from one of the caller’s groups, and answer 403 without either. If your application declares the permissions a group may grant, declare :workflow/read among them. A transition needs no grant: its :required-permissions decide.

In a multi-tenant application every route answers 403 to a caller outside the request’s tenant: not a member of it, not one of its users and not a platform admin. Without a tenant, only a user of no tenant is served. Inside a tenant, the admin that reads is the tenant’s: a platform admin, an admin by membership, or an admin user of that tenant; another tenant’s admin is not one. A grant counts there only when its scope names the tenant, {:tenant-id "<id>"}. The admin pages under /web/admin/workflows admit the tenant’s admins on the same terms, so a tenant admin sees their own tenant’s instances.

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 the instance and its audit entry. The instance carries the transitions available from its new state to the caller, as GET /instances/:id does:

{"instance":    {"id":            "0d4b7f3e-2a61-4c55-8f0e-9b7a1c2d3e4f",
                 "workflow-id":   "order-workflow",
                 "entity-type":   "order",
                 "entity-id":     "5b1c9a52-6f0e-4d7a-9a57-2d9e3c1b8f40",
                 "current-state": "delivered",
                 "available-transitions": [{"id": "paid", "to": "paid", "enabled": true}],
                 "created-at":    "2026-09-27T12:00:00Z",
                 "updated-at":    "2026-09-27T12:05:00Z"},
 "audit-entry": {"id":          "7c1e2d3f-4a5b-4c6d-8e9f-0a1b2c3d4e5f",
                 "instance-id": "0d4b7f3e-2a61-4c55-8f0e-9b7a1c2d3e4f",
                 "transition":  "delivered",
                 "from-state":  "entered",
                 "to-state":    "delivered",
                 "actor-id":    "2f6c8a1e-5b3d-4e7f-9a0b-1c2d3e4f5a6b",
                 "actor-roles": ["admin"],
                 "occurred-at": "2026-09-27T12:05:00Z"}}

A transition not enabled for the caller has "enabled": false and a reason: insufficient-permissions, or guard-rejected when a guard refuses it. 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'",
           "correlation-id": "3f2b6c1e-8d4a-4e7b-9c1f-2a5d6e7f8a9b"}}

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"}]

One shape for what may follow

available-transitions is the same list wherever it appears, next to the state it was computed from: on the instance here, and on a scaffolded entity’s workflow after POST /api/v1/<entities>/:id/transition. Each item has id, to and enabled, a label when the transition has one, and a reason when it is not enabled. The entity’s transition answers:

{"id":       "5b1c9a52-6f0e-4d7a-9a57-2d9e3c1b8f40",
 "number":   "A-1",
 "status":   "delivered",
 "workflow": {"instance-id": "0d4b7f3e-2a61-4c55-8f0e-9b7a1c2d3e4f",
              "state":       "delivered",
              "available-transitions":
              [{"id": "paid", "to": "paid", "label": "Paid", "enabled": true},
               {"id": "void", "to": "void", "enabled": false, "reason": "insufficient-permissions"}]}}

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}]

In the caller’s transaction

start-workflow! and transition! take an optional transaction before their input, so an entity and its workflow commit or roll back together. Pass an open transaction on the workflow store’s datasource: a next.jdbc connection or a platform with-transaction context.

(jdbc/with-transaction [tx ds]
  (orders/create! tx order)
  (ports/start-workflow! engine tx {:workflow-id :order-workflow
                                    :entity-type :order
                                    :entity-id   (:id order)}))

A datasource is refused, since it would autocommit. A nil transaction writes on the platform transaction open on this thread on the store’s datasource, when there is one, else on the store’s own connection. Hooks run before the caller commits; side-effect jobs join the transaction only on the database job queue. Code inside a platform with-transaction without a handle on it can pass (wagoe.platform.database/current-transaction). Scaffolded entities do: a create and its workflow instance are written in one transaction.

Tenancy

With the tenant module on, each tenant schema has its own workflow_instances and workflow_audit. The tenant module runs workflow’s migrations in each tenant schema, the same series public runs, when it provisions a tenant and in every existing tenant schema at boot. A tenant request runs in a transaction bound to its schema, and the store reads and writes through that transaction, so a tenant reaches only its own instances. Outside a tenant, and in a single-tenant application, the tables in public are used as before.

Tenant work never falls back to public. A tenant schema without the tables, and tenant work outside a tenant transaction (on H2 under :allow-h2?, all of it), fail with a :configuration-error. Hooks run with the tenant bound, and side-effect jobs carry it in :metadata {:tenant-id …​}; with the tenant module on, job workers run such a job in the tenant’s schema, and fail it without one. A job for a queue outside the database is enqueued once the transaction commits, so a worker never sees a transition that may yet roll back. process-auto-transitions! runs in public and in every active tenant’s schema; its result counts tenants whose run failed under :failed-tenants.

Instances a tenant started before workflow was tenant-scoped are in public. Move each tenant’s rows into its schema, or they stay visible only outside any tenant.

A library ships tables of its own for tenant schemas by naming their migrations under :tenant-paths in its wagoe/migration-paths/<lib>.edn manifest. Those run only when the library’s module is switched on in :active.

Joining an entity to its instance

workflow_instances.entity_id is text, because an entity need not be keyed by a UUID. Its generated, indexed entity_uuid column holds the same id as a UUID, or NULL when it is not one, so a UUID-keyed table joins it without a cast on PostgreSQL, H2 and SQLite alike:

SELECT i.number, w.current_state
FROM invoices i
JOIN workflow_instances w ON w.entity_type = 'invoice' AND w.entity_uuid = i.id

An entity keyed by anything else joins on entity_id. migrate up adds the column, rewriting workflow_instances once on PostgreSQL. The boot adds it only in the dev, test and acc profiles; in any other it changes no table and logs a warning naming bb migrate up.

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