3. DMN and FEEL
How to keep business rules out of the process diagram and out of code. DMN (Decision Model and Notation) is a standard for decision tables; FEEL is the expression language used inside them and inside Camunda 8 processes. Used from a business rule task in BPMN.
Why a decision table
flowchart LR
P["Process variables: age, employment"] --> T["Business rule task"]
T --> D[("DMN table: Quarantine Duration")]
D --> R["Result variable: quarantine_duration"]
R --> N["Next steps use the result"]
- Rules change more often than process flow. A table can be edited and redeployed without touching the diagram or code.
- Business people can read a table. They cannot read nested
ifstatements.
Example from the tutorial: quarantine duration
Inputs: Employment Category and Age. Output: Quarantine Duration (an ISO 8601 duration).
| # | Employment Category | Age | Quarantine Duration | Why |
|---|---|---|---|---|
| 1 | "critical infrastructure" |
<=30 |
"P5D" |
Young, recovers fast, and the role keeps society running |
| 2 | "non-critical infrastructure" |
<=30 |
"P7D" |
Young, recovers fast |
| 3 | (any) | >30 |
"P10D" |
Older people take longer to be sure |
- The hit policy is Unique: at most one rule may match.
- The output (
P5D,P7D,P10D) goes straight into a timer as=quarantine_duration, so a rule result drives the process. - The task's Called decision ID is
Decision_Quarantineand the Result variable isquarantine_duration. The ID in the process must match the decision's ID.
<bpmn:businessRuleTask id="rule" name="Determine length of quarantine">
<bpmn:extensionElements>
<zeebe:calledDecision decisionId="Decision_Quarantine" resultVariable="quarantine_duration" />
</bpmn:extensionElements>
</bpmn:businessRuleTask>
Hit policies
| Policy | Letter | Meaning | Example use |
|---|---|---|---|
| Unique | U | Only one rule can match (error if several) | Mutually exclusive bands |
| First | F | First matching rule wins (order matters) | Priority lists, defaults at the bottom |
| Priority | P | Highest-priority matching output wins | Ranked outcomes |
| Any | A | Several can match but must agree | Redundant rules |
| Collect | C | All matches returned as a list (or + sum, < min, > max, # count) |
Totalling fees or surcharges |
| Rule order | R | All matches, in table order | Ordered checklists |
| Output order | O | All matches, in output priority order | Sorted results |
Collect with sum, e.g. insurance loading
| Age | Smoker | Loading |
|---|---|---|
>50 |
- | 20 |
| - | true |
30 |
<25 |
- | 10 |
A 55-year-old smoker matches rows 1 and 2, so with Collect (+) the result is 50.
Input and output cells (FEEL unary tests)
| Write | Matches |
|---|---|
5 |
exactly 5 |
<=30, >30 |
comparisons |
[18..65] |
18 to 65 inclusive |
(18..65) |
18 to 65 exclusive |
"gold", "silver" |
either value |
not("closed") |
anything but "closed" |
(empty) or - |
any value |
FEEL: the expression language
In a BPMN field, an expression starts with =. Without =, it is plain text.
= 1 + 2 * 3 # 7
= "Hello, " + name # string join
= order.total > 1000 and customer.vip # boolean
= if age >= 18 then "adult" else "minor"
= items[price > 100] # filter a list
= sum(items.price) # 3 + 5 + ... of a field
= count(items)
= for i in items return i.price * i.qty # transform each element
= some i in items satisfies i.price > 100
= every i in items satisfies i.qty > 0
= contains(email, "@")
= upper case(name)
= string length(code)
= date("2026-10-29") + duration("P7D")
= now() + duration("PT2H")
= date and time(dueDate) < now()
Data types: number, string, boolean, date, time, date-time, duration (ISO 8601: P1DT2H), list [1,2,3], context (object) { name: "A", age: 3 }, and null.
Where FEEL appears in Camunda 8
| Place | Example |
|---|---|
| Sequence flow conditions | =isZombie = true |
| Timers | =quarantine_duration |
| Message correlation key | =person_uuid |
| Input / output mappings | =customer.email mapped to to |
| Multi-instance input collection | =order.items |
| Task headers and assignee | =manager |
| Script tasks | =total * 1.15 |
Null handling and pitfalls
- A missing variable is null, and most operations on null give null. Use
= if x = null then 0 else x, or defaults, to be safe. - Strings in FEEL need double quotes.
"critical infrastructure"is a string;criticalis a variable name. - Comparing a string to a number is false, not an error. Check variable types in Operate.
- A path through a missing key (
order.address.city) gives null, not a crash. If a required value is missing, the engine raises an incident instead (Troubleshooting). - Names with spaces need backticks:
`order total`.
Chaining decisions: DRD
A Decision Requirements Diagram links decisions: the output of one is the input of the next.
flowchart LR
I1["Input: age"] --> D1["Decision: age band"]
I2["Input: income"] --> D2["Decision: affordability"]
D1 --> D3["Decision: loan offer"]
D2 --> D3
- Keep each table small and focused; combine them in a DRD.
- Use literal expressions in a decision for a single formula and decision tables for rule lists.
Testing decisions
- Write a few example rows next to the table (inputs and the expected output) and check each in the Modeler's decision test or a unit test.
- Test the boundaries (age 30 and 31 here) and the empty values.
- Keep tables versioned in Git with the process.
- Overlapping rules under Unique cause runtime errors: review tables for gaps and overlaps whenever they change.