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

  1. Create an XML file under src/main/resources/workflows/ (one file may contain several <workflow> elements).

  2. Define the workflow: identity attributes, one <start> node first, then the graph.

  3. 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

name

yes

workflow name; identity is the natural (model, jsonModel, name) key — so custom-model workflows (which all share model = com.axelor.meta.db.MetaJsonRecord) are distinguished by jsonModel

model

yes

fully qualified name of the bound model

jsonModel

no

the custom model name; when set, model is com.axelor.meta.db.MetaJsonRecord

id

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

priority

no

picks the winner among active definitions with the same (model, jsonModel, name); defaults to 20

sequence

no

execution order when several workflows of the same model fire on one event, lowest first; defaults to 0

active

no

defaults to true

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 sequence order, 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

on

one of these

create, update, timer or manual

message

one of these

the message name this workflow starts on, a literal, written in place of on (on="message" is refused). Refuses watch, delay, unit and repeat="episode"

title / description

no

free text for the designer and the run viewer

watch

no

comma-separated field names; on update and timer, the workflow is only considered when one of the watched fields changed (for a reference field, when the reference itself changed). Persistent scalar and single-reference fields and custom fields are accepted, not collections. Refused on create, manual and message. With repeat="episode" or once it must list every field when reads

when

no

sandboxed JavaScript condition (see below). Required on update with repeat="episode", which could never fire without one

repeat

no

once, always or episode (default). Refused on manual; a message trigger refuses episode — see below

delay / unit

for timer

how long the when condition must hold before the graph runs; unit is minute, hour or day. A timer needs delay, unit and a when or a watch (without when, each watched change re-arms the timer). Timer triggers accept only repeat="episode".

next

yes

the first node to execute

Trigger kinds

create

fires after a record of the model is created (and when holds).

update

fires after a record is updated. Use watch to pre-filter cheaply: the engine skips the workflow entirely when none of the watched fields changed.

timer

arms a durable timer when when becomes true on a save; after delay unit the 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 key must be this workflow’s own record id.

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 (self.orderNo) is not wrong; it is simply not addressing a start event. Nothing reports it, because from the message’s side nothing went wrong: the message is offered, no definition takes it, and it expires.

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’s id; for reference fields, their id)

  • old — the same fields before the save (update only), and only with repeat="always". With the default episode and with once the engine already compares before and after itself, so old in the when is 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' &amp;&amp; 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.