Workflow Definitions
Workflows are defined in XML, validated against an XSD, and loaded into
MetaWorkflow records — mirroring how views and actions work. Workflows built in
the Studio designer are the same format and load
through the same gate.
Creating a workflow in a module
-
Create an XML file under
src/main/resources/workflows/(one file may contain several<workflow>elements). -
Define the workflow: identity attributes, one
<start>node first, then the graph. -
Restart or update the module — the file validates and the workflow activates.
<?xml version="1.0" encoding="UTF-8"?>
<workflows xmlns="http://axelor.com/xml/ns/workflows">
<workflow
name="order-confirmation" (1)
model="com.axelor.sale.db.Order" (2)
priority="20" (3)
active="true"> (4)
<start .../> (5)
<!-- other nodes -->
<end id="done"/>
</workflow>
</workflows>
| 1 | the workflow name (required) — with model, the workflow’s identity |
| 2 | the fully qualified model name (required) |
| 3 | override priority (default 20) — the highest active definition per (model, jsonModel, name) wins |
| 4 | definitions are active by default; an inactive definition never fires |
| 5 | exactly one <start> node, written first, carrying the trigger |
| Attribute | Required | Description |
|---|---|---|
|
yes |
workflow name; identity is the natural |
|
yes |
fully qualified name of the bound model |
|
no |
the custom model name; when
set, |
|
no |
the stable identity that runs, tasks and state bind to — set it on anything you ship, and do not change it once the workflow is deployed. It also disambiguates module overrides, as for views |
|
no |
picks the winner among active definitions with the same
|
|
no |
execution order when several workflows of the same model fire
on one event, lowest first; defaults to |
|
no |
defaults to |
Loading and overriding
An invalid definition is rejected as a whole: the file must pass the schema, and
the validator enforces the graph-wide rules — every next resolves, all nodes are
reachable, the trigger is coherent.
To override a workflow shipped by another module, define one with the same model
and name and a higher priority — among active definitions, the highest priority
wins.
Two active definitions with the same (model, name) and the same priority
are rejected.
|
A definition’s identity is a stable xmlId that survives undeploy/redeploy, so a
run or task already parked against it keeps resolving correctly even after the
definition row is recreated.
Editing the graph is allowed while runs are parked on it, and it does not disturb
them: every change to the XML mints a new revision, and each run finishes on the
revision it started with. See
Execution.
Removing a definition, or undeploying it from Studio,
is still rejected while it has anything in flight — a parked run, a pending
task, or an armed timer/once trigger — because a removal takes the
identity with it and leaves nothing for those instances to be deployed against.
Complete or cancel them first. Toggling active alone changes nothing about
parked instances: it only gates future dispatch.
Customizing a shipped workflow
When you need to adapt a workflow that a module ships, prefer composition before copying its graph so the shipped workflow keeps receiving the module’s updates.
- Prefer composition
-
To add behavior — notify someone, write a log, start something else — author a separate complementary workflow on the same model. Independent workflows on one event run in
sequenceorder, so yours runs alongside the shipped one without touching it. This is the recommended approach for most customizations. - Override the workflow (last resort)
-
To restructure the shipped graph — insert or remove steps in the middle — import it into Studio and edit the copy. The Studio copy is saved at a higher priority, so it wins at dispatch and supersedes the module original.
| A workflow override is a full-graph copy, i.e. a fork. Once you override a shipped workflow, later module updates to that workflow are shadowed — your copy keeps winning and does not adopt them automatically. Such changes are called out in the release changelog; when you see one, review your override and either re-apply the module’s changes into it or drop the override to adopt the new base. Prefer composition when possible to avoid this fork. |
Triggers
The <start> node carries the trigger configuration:
<start id="start" on="update" watch="confirmed,totalAmount"
when="self.confirmed == true" repeat="episode" next="check"/>
| Attribute | Required | Description |
|---|---|---|
|
one of these |
|
|
one of these |
the message name this workflow starts on, a literal,
written in place of |
|
no |
free text for the designer and the run viewer |
|
no |
comma-separated field names; on |
|
no |
sandboxed JavaScript condition (see below). Required on
|
|
no |
|
|
for |
how long the |
|
yes |
the first node to execute |
Trigger kinds
create-
fires after a record of the model is created (and
whenholds). update-
fires after a record is updated. Use
watchto pre-filter cheaply: the engine skips the workflow entirely when none of the watched fields changed. timer-
arms a durable timer when
whenbecomes true on a save; afterdelayunitthe scheduler re-checks the condition and runs the graph. Disarms if the condition turned false in the meantime. Timers are cluster-safe: exactly one instance processes a due record. manual-
never fires from saves; the workflow runs only through the programmatic API (see below), or as the target of a
<call>. message-
never fires from saves either. It runs when another workflow announces the message this one names, with no
<call>coupling the two and no run existing here until the message arrives. See Starting on a message.
Starting on a message
<!-- on Invoice: the payment workflow announces, as it already does -->
<send id="tell" message="order-paid" key="self.order.id" next="done"/>
<!-- on Order, a different workflow, which had no run at all until this arrived -->
<start id="s" message="order-paid" repeat="once" next="ship"/>
The contrast with a <wait message⇒ is the whole feature: there, an Order run
was already parked waiting to hear this. Here there is no run until the message
arrives.
|
The message’s A start event has no record yet, so the key is the only thing that can name one — it is read as an id in this definition’s model. That is a contract between two definitions that nothing across them can check, the same footing the name has always stood on. A sender keying by a business number ( |
The run reads what woke it through the message binding — message.name,
message.key, message.data, message.sentAt — so a payload is how a start
event learns anything the record cannot tell it.
when still applies, evaluated against the record the key named, so a workflow can
start on a message and still decline. repeat="once" is the usual answer for a
handoff: the same message twice starts one run. repeat="episode" is refused —
an episode is a condition going false and true again on a record, and a message
has no such edge. watch, delay and unit are refused for the same reason:
each describes a record change or a clock.
There are no signal start events. A signal has no key, so it could name no record, and starting one run per record of a model from a single announcement has unbounded cost.
What stops two workflows starting each other for ever
A workflow that sends x and a workflow that starts on x can name each other in
a cycle. Each turn is a separate run in a separate transaction, so neither
workflow.max-depth nor a node’s maxVisits can see it.
What does is a cascade: a chain of deliveries with no external input between
its turns. Every message carries the cascade it belongs to and how deep it is, and
past workflow.message.max-depth (10) the sending node fails.
Parking on a timer, a condition or a task ends a cascade, because something outside the engine had to happen. Parking on a message does not — that is exactly the ping-pong being bounded. So a long-lived process that sends one message per human step is never bounded by this, and two definitions turning at poll speed stop after ten.
The when condition
when answers exactly one question — "did the thing I react to just happen on
this record?" — and is evaluated in memory against the record’s own fields:
-
self— the record’s fields after the save (scalars, custom fields and the record’sid; for reference fields, their id) -
old— the same fields before the save (updateonly), and only withrepeat="always". With the defaultepisodeand withoncethe engine already compares before and after itself, sooldin thewhenis refused (start-when-invalid)
<!-- episode (the default): when alone says "became true" -->
<start id="s" on="update" watch="status" when="self.status == 'CONFIRMED'" next="go"/>
<!-- always: fires on every save, so the change is written out with old -->
<start id="s" on="update" watch="status" repeat="always"
when="self.status == 'CONFIRMED' && old.status != 'CONFIRMED'" next="go"/>
Comparing an enum
An enum field reads as its item’s name, everywhere a workflow reads it — a
when, old, a split flow, a wait or boundary condition and a script.
That holds for an enum whose items carry a value too: with
<enum name="OrderStatus">
<item name="OPEN" value="open"/>
<item name="CLOSED" value="closed"/>
</enum>
the comparison is self.status == 'OPEN'; 'open' is only how the column
stores it and never matches. A script assigns by name the same way
(self.status = 'CLOSED'). A plain string field with a selection is not an
enum — it holds the selection’s value, and is compared by that.
Compare with ==, not ===. A when sees the name as a string, but a
split flow, a wait condition and a script see the record’s own enum
object, which is only loosely equal to its name.
Stepping onto a relation
A reference may be followed exactly one step, and that step yields the other
record’s stored values — its scalars, its custom fields and its id:
when="self.customer.vip == true"
What does not survive the step is every relation of that record.
self.customer.country is a reference, so it reads as undefined and the
condition simply never holds — and because the trigger never fired, there is no
run recorded to explain why. Only a second step written out in full
(self.customer.country.name) is refused on save; a misspelled field on the
other record is not.
A step also reads the other record as it stands now, not as it stood before
the save: old.customer.vip tells you about the customer the record pointed to
before, with that customer’s current values. Only the change of relation is
history.
So take the step only for a stored value the other record carries. Anything
further belongs inside the graph — read it from a split flow or script
node, which execute against the full record.
Apart from that one step, the when evaluator is sandboxed: no
database access, no services, no Java interop.
|
Repeat policies
always-
fires on every matching event.
episode-
(default) fires on the false→true transition of
when, then not again until the condition has turned false. Stateless — the episode is computed from the old/new snapshots of the save itself. once-
fires a single time per record, ever. This is durable state: the engine records consumption, and re-saving can never fire the workflow again.
Programmatic execution
Workflows can be run from code — an action-method, a service, or a REST call —
through WorkflowEngine:
@Inject private WorkflowEngine workflowEngine;
// force-run a specific workflow on a record (audited as a manual run)
workflowEngine.run("order-confirmation", "com.axelor.sale.db.Order", orderId);
// run only if the workflow's own trigger conditions match,
// honoring watch, when and the repeat policy (incl. the once budget)
workflowEngine.runIfMatched("order-confirmation", "com.axelor.sale.db.Order", orderId);
run is the natural companion of on="manual" workflows; runIfMatched lets
code paths participate in the same gating as saves.
Suppressing dispatch
Bulk imports and migrations usually must not fire workflows for every touched record. Suppress dispatch for a code block:
try (var ignored = WorkflowContext.suppress()) {
// saves in here do not dispatch workflows
}
| The advanced data import runs under this scope: an import never fires workflows, whatever it saves. |