Workflow Hooks
Developer guide for the Enezaframework workflow engine. Part of the Workflow series.
Table of Contents
- What It Is
- The Events
- Registering Hooks
- Quick Start
- When Hooks Run
- Sync vs Async
- Rules and Guardrails
- Related Guides
- A Note on Testing
What It Is
Workflow hooks are the engine's internal extension point: a developer registers a plain Python callable that runs when the engine executes, then uses it to update other records or run business logic. They work for both workflow kinds:
- approval workflows (
workflow_kind='approval'), and - state-based workflows (
workflow_kind='state_based').
The interface is a single project-level registry, WorkflowTypeConfig.WORKFLOW_HOOKS, declared in the
project config class reached through settings.MY_PROJECT_WORKFLOWS_CONFIG
(framework/settings.py:32, framework/workflowconfig/hooks.py). It follows the same pattern as
WORKFLOW_TYPE_REGISTRY and WORKFLOW_STATE_CHOICES.
flowchart LR
A["Workflow engine event"] --> B["emit_workflow_event()"]
B --> C["transaction.on_commit"]
C --> D["WorkflowHookRegistry.iter_handlers()"]
D --> E["WORKFLOW_HOOKS config"]
D --> F["register() runtime hooks"]
E --> G["handler(context)"]
F --> G
G --> H{"raised?"}
H -->|no| I["done"]
H -->|yes| J["logger + WorkflowTransaction(action_type='hook')"]The Events
| Event | Kind | Fires when |
|---|---|---|
submitted |
both | A document is submitted and the workflow instance starts. |
step_entered |
approval | An approval step is entered and its approvers are assigned. |
approved |
approval | An approval step reaches its approval threshold. |
rejected |
approval | A step is rejected (terminal). |
changes_requested |
approval | Changes are requested on a step (terminal until resubmission). |
state_entered |
state | A branch arrives at a business state (start, move, fork child, join continuation). |
state_changed |
state | A user successfully moves a branch (once per transition). |
completed |
both | The workflow finishes with no active work left. |
cancelled |
both | The workflow is cancelled through a cancel service. |
state_entered and state_changed are complementary: state_entered is "the document is now in
state X" (including automatic arrivals), and state_changed is "a user moved the document" (even when
the destination is a FORK/END node). A plain user move fires both. See the
reference for the exact context of each event.
Registering Hooks
Add WORKFLOW_HOOKS to the project config. The recommended package form puts the dict in the
workflowconfig/hooks.py submodule:
# workflowconfig/hooks.py
WORKFLOW_HOOKS = {
'*': {
'submitted': ['myapp.hooks.audit_submit'],
},
'IncidentWorkflow': {
'state_entered': [
{'handler': 'frameworkapp.workflow_hooks.on_incident_closed',
'to_state': 'closed'},
],
'completed': ['frameworkapp.workflow_hooks.on_incident_completed'],
},
'CreditCardWorkflow': {
'approved': ['frameworkapp.workflow_hooks.on_credit_card_approved'],
},
'InvoiceWorkflow': {
'approved': ['myapp.hooks.post_invoice'],
},
}
With the single-file form (workflowconfig.py), put the same dict inside class WorkflowTypeConfig:.
This repository ships two complete, active samples in frameworkapp/workflow_hooks.py, registered in
framework/workflowconfig/hooks.py:
| Workflow | Kind | Sample events |
|---|---|---|
IncidentWorkflow |
state-based | submitted, state_changed, state_entered (filtered to closed), completed |
CreditCardWorkflow |
approval | submitted, approved, rejected, completed |
The credit-card handlers show the "change another record after approval" pattern; because the
accounting.CreditCardTransaction model is not part of this repository, they guard get_document()
and log instead of writing.
Each entry is either:
- a dotted path to a callable (runs for every matching event), or
- a dict
{'handler': <dotted path>, ...filters}with optional exact-match filters (from_state,to_state,step_name,document_model).
The '*' workflow key runs for every workflowname. A handler is any callable that takes one
context object and returns None:
# myapp/hooks.py
def post_invoice(context):
if context.to_state != 'approved':
return
invoice = context.get_document()
# ... update other records ...
Identifying the record the workflow is attached to
Every event carries the document's identity, so a hook can locate it without loading it:
| Access | Value | Query |
|---|---|---|
context.document_model |
app_label.ModelName, e.g. frameworkapp.IncidentReport |
no |
context.document_id |
The primary key as text, e.g. '42' |
no |
context.get_document_id() |
Native pk (int/UUID), or the raw string if the row is gone | yes |
context.get_document() |
The document instance, or None |
yes |
from django.apps import apps
def on_approved(context):
model, record_id = context.document_model, context.get_document_id()
# update the record without loading it:
apps.get_model(model).objects.filter(pk=record_id).update(is_active=False)
Quick Start
- Write the handler in a module your project already imports (for example
myapp/hooks.py):
python
def on_incident_submitted(context):
incident = context.get_document()
if incident is None:
return
# ... business logic ...
-
Register it in
WorkflowTypeConfig.WORKFLOW_HOOKS(see above). The live samples in this repo areframeworkapp/workflow_hooks.py(IncidentWorkflowandCreditCardWorkflow), registered inframework/workflowconfig/hooks.py. -
Verify the registry resolves:
bash
python manage.py check_workflow_hooks --json
python manage.py migrateonce, to apply thehookhistory action type (enezaworkflowengine/migrations/0006_alter_workflowtransaction_action_type.py).
When Hooks Run
Hooks are scheduled with transaction.on_commit, so they run after the engine's own writes are
committed. That gives two guarantees:
- A hook can never roll back or veto a workflow transition.
- A hook failure is caught, logged, and recorded as a
hookhistory entry (WorkflowTransaction,enezaworkflowengine/services/workflow_hooks.py); the remaining hooks still run and the document still moves.
The trade-off is that a hook failure is invisible to the user's HTTP response — inspect the document's workflow history or the server log.
Approval events (
step_entered,approved,rejected,changes_requested,completed) and completion run inside background tasks, so they require the task worker (db_worker) to be running. State-based events run in the request thread.
Sync vs Async
By default hooks run inline after commit. Set WORKFLOW_HOOKS_ASYNC = True to enqueue them through
django_tasks instead (the same shape as WORKFLOW_STATE_ASYNC); it requires
python manage.py db_worker. If enqueueing fails, the hooks fall back to inline execution.
A context variable depth guard (WORKFLOW_HOOKS_MAX_DEPTH, default 10) stops runaway recursion when
a hook triggers more workflow work.
Rules and Guardrails
- Do not write the engine-owned fields (
workflowstate,Approvalstatus). The engine keeps them in sync; writing them from a hook races the engine. - Hooks are keyed by
workflowname, not template version, so they run for whichever template version each instance is pinned to. - A hook that cannot be imported is skipped with a warning; use
check_workflow_hooksto catch this. - Keep handlers idempotent.
completedandcancelledfire once per instance, but a state can be re-entered and a document can be resubmitted.
Related Guides
- Workflow Hooks Reference — events, context fields, filters, settings, command and troubleshooting.
- Workflow Project Configuration — the
workflowconfig/package pattern. - State-Based Workflow — the state engine and its transitions.
- Workflow — Developer Index
A Note on Testing
Per project rules, the developer or agent does not run or claim test results. The user is solely responsible for all testing and validation of the output described in this guide.