Business Exception Error Handling
tSM lets a BPMN author catch a specific business error raised with
#businessException(code, message) using a standard BPMN error boundary (or error
start) event whose errorCode equals the business code. This turns a validation
failure into an explicit, modeled correction path instead of a failed job and an
incident — while leaving every unhandled, technical, and real BpmnError case
behaving exactly as before.
For general transaction behaviour see Process Transactions; for SpEL specifics see SpEL and Transactions.
Four outcomes, four meanings
When an activity finishes, one of four things can happen. Model each deliberately:
| Outcome | What it means | How to model it |
|---|---|---|
| Expected result | The activity succeeded. | Normal sequence flow. |
| Business exception | A business rule was violated (the input is wrong, the customer is not eligible, …). The user or an upstream branch can fix it. | #businessException(code, message) + a BPMN error event with the same errorCode. |
| BPMN error | An explicit, modeled control-flow signal from a Java delegate or the External Task API. | throw new BpmnError(code) + a BPMN error event. |
| Technical exception | Something broke (NPE, timeout, division by zero, infrastructure). Nobody can "correct" it in a form. | Leave it unhandled → retry → incident. Do not catch it with a business handler. |
A business error handler catches only business exceptions whose code matches. Technical exceptions and business exceptions with a different code are not caught — they follow the standard retry / incident path.
Catching a business exception
Raise the business exception in an activity expression:
#businessException(
"ORDER.INVALID",
"The order must be corrected before it can be submitted."
)
Declare a matching BPMN error and catch it with a boundary event using the same
errorCode:
<bpmn:error id="Error_OrderInvalid"
name="Order requires correction"
errorCode="ORDER.INVALID" />
<bpmn:boundaryEvent id="Boundary_OrderInvalid" attachedToRef="Activity_validate">
<bpmn:errorEventDefinition errorRef="Error_OrderInvalid"
camunda:errorCodeVariable="errorCode"
camunda:errorMessageVariable="errorMessage" />
</bpmn:boundaryEvent>
The boundary event fires only when a business exception with
businessCode == "ORDER.INVALID" reaches it. The comparison is exact and
case-sensitive; the code must be non-blank. Use a stable, namespaced code (see
Recommended business codes).
tSM does not convert #businessException into a Camunda BpmnError. Instead it
extends error-event matching: a business exception is caught when the BPMN
error.errorCode equals the exception's businessCode. Because the exception stays
an ordinary Java exception, an unmatched one is re-thrown unchanged — so the
existing rollback / retry / incident behaviour is fully preserved.
Matching, propagation, and fallback
- Code matches a handler in scope → that BPMN branch is taken. No retry, no incident. The current transaction may continue and commit (see Transactional impact).
- Code does not match any handler in the current scope → the error propagates to enclosing scopes (embedded subprocess, event subprocess, and across a call activity to the parent).
- No matching handler anywhere → the original exception is re-thrown:
- synchronously the API call returns the business error;
- in an async job the transaction rolls back, the job retries, and after retries are exhausted an incident is created.
- Technical exception → never caught by a business handler; standard error / incident behaviour applies.
Supported scopes
Catching works for a boundary event on a service task, a boundary event on an embedded subprocess, an error start event of an event subprocess, and propagation across a call activity to a parent boundary.
Retry and incidents
A handled business exception does not create a failed job or an incident — it is normal, modeled flow.
An unhandled business exception keeps the current behaviour: in an async job it rolls back, retries, and finally raises an incident. The incident message keeps the tSM business/technical split (the clean business message is shown to users; the technical detail — activity, expression, position — is stored separately), exactly as before this feature.
Error variables in the handler branch
For a matched business exception the correction branch receives:
camunda:errorCodeVariable— the businesscode(e.g.ORDER.INVALID);camunda:errorMessageVariable— the clean business message (no technical detail);
plus a fixed set of context variables, scoped local to the handler branch (so parallel branches never overwrite each other's context):
| Variable | Contents | Always set? |
|---|---|---|
tsmBusinessErrorCode | the business code | yes |
tsmBusinessErrorMessage | the clean, user-facing message | yes |
tsmBusinessErrorTechnicalMessage | technical detail (activity / expression / position) | only for process-spel |
tsmBusinessErrorActivityId | the failing activity id | only for process-spel |
tsmBusinessErrorSource | how the error was raised (process-spel or api-business-exception) | yes |
tsmBusinessErrorCode, tsmBusinessErrorMessage and tsmBusinessErrorSource are always
non-blank strings. tsmBusinessErrorTechnicalMessage and tsmBusinessErrorActivityId are
optional and can be null — they are filled only for an exception raised from a process
expression (tsmBusinessErrorSource = process-spel), because only that path knows the
activity and the position in the expression. A business exception thrown from a script or
from Java code (tsmBusinessErrorSource = api-business-exception) carries neither, and both
variables are then set to null — not to an empty string. Check for null before any string
operation over them, in a script as well as in a form field binding.
camunda:errorCodeVariable, camunda:errorMessageVariable and the tsmBusinessError*
variables are written local to the handler branch. This is what keeps parallel branches
from overwriting each other's context, but it differs from Camunda's default, which writes
errorCodeVariable / errorMessageVariable into the process-instance root scope where they
stay readable until the instance ends.
Consequence: after a parallel-gateway join (or when the interrupted concurrent branches are
merged back), these variables are no longer visible to downstream steps. Read the error code /
message within the handler branch, before any join — or copy the value you need into a
variable of your own (setVariable) if a step past the join must see it.
Relation to #try().catch()
#try(...).catch(...) is local SpEL error handling and takes precedence over a
BPMN handler. If #try() consumes the business exception, the BPMN error event does
not fire:
#try(riskyExpression()).catch(fallbackValue)
If the catch branch itself re-throws #businessException(code, message), the new
exception can again be caught by a BPMN error event.
Script Binding
A business exception raised from a script executed via tsmScriptDelegateExecutor
is caught the same way — the matcher walks the exception cause chain to find the
business code:
<bpmn:serviceTask id="Activity_validate"
camunda:delegateExpression="#{tsmScriptDelegateExecutor}">
<bpmn:extensionElements>
<camunda:field name="scriptCode" stringValue="order.validate" />
</bpmn:extensionElements>
</bpmn:serviceTask>
<bpmn:boundaryEvent id="Boundary_OrderInvalid" attachedToRef="Activity_validate">
<bpmn:errorEventDefinition errorRef="Error_OrderInvalid" />
</bpmn:boundaryEvent>
If the script calls #businessException("ORDER.INVALID", "…"), the boundary above
catches it. A script raises the exception without expression context, so on this path
tsmBusinessErrorSource is api-business-exception and both
tsmBusinessErrorTechnicalMessage and tsmBusinessErrorActivityId are null — see
Error variables in the handler branch.
Transactional impact
A caught BPMN business error is not a rollback mechanism. Once the handler is activated, the current process transaction may continue and commit the changes made before the error. Design accordingly:
- Do business validation before side effects.
- Put the handler on the smallest activity / subprocess scope that makes sense.
- Separate external / non-idempotent operations with transaction boundaries
(
camunda:asyncBefore/asyncAfter). - Beware a service annotated
@Transactionalthat marked the shared transactionrollback-onlybefore the error was caught — the handled branch is then lost, see Known limitations below and Process Transactions.
Recommended business codes
Use a stable, namespaced, uppercase code that reads as a contract between the code that raises it and the BPMN that catches it:
ORDER.INVALIDCUSTOMER.NOT_ELIGIBLEPAYMENT.DECLINED
Avoid free-form, localized, or message-derived codes — the match is exact and case-sensitive.
Enabling / disabling
Business-code matching is on by default and affects every error boundary event and error
start event in every deployed BPMN. To fall back to native-only matching (real BpmnError /
FQCN, as before this feature) without rolling back the image, set:
tsm.process.business-error.enabled=false
With the flag off the parse listener is not installed and error definitions are left exactly as Camunda parsed them.
Known limitations
- Listeners. A business exception raised from an execution listener or a task listener is caught by a matching boundary in the current characterization, but the supported listener contexts may evolve — prefer raising business exceptions from the activity expression / delegate rather than from listeners.
- Parallel / multi-instance. Each branch keeps its own error context
(
tsmBusinessError*variables are branch-local); one branch's error never overwrites another's. rollback-onlytransactions. Arollback-onlymark wins over the handler. If a@Transactionalservice marked the shared transactionrollback-onlybefore the business exception was thrown, the boundary still matches and the handler branch still runs — but the commit then fails withUnexpectedRollbackExceptionand nothing from the handler branch is persisted: no user task, no activity instance, no error variables. In an async job the job is retried with the rollback message recorded on it, and once the retries are exhausted it ends as an ordinaryfailedJobincident — so a handled business error still becomes an incident. Keep validation ahead of transactional side effects, and markrollback-onlyonly when the whole unit of work is meant to be discarded.#bpmnError(). There is no SpEL helper to raise a realBpmnError; that remains an explicit control-flow mechanism for Java delegates and the External Task API. Catching#businessExceptiondoes not require it.