Skip to main content
Version: 2.4

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:

OutcomeWhat it meansHow to model it
Expected resultThe activity succeeded.Normal sequence flow.
Business exceptionA 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 errorAn explicit, modeled control-flow signal from a Java delegate or the External Task API.throw new BpmnError(code) + a BPMN error event.
Technical exceptionSomething 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).

How it works

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 business code (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):

VariableContentsAlways set?
tsmBusinessErrorCodethe business codeyes
tsmBusinessErrorMessagethe clean, user-facing messageyes
tsmBusinessErrorTechnicalMessagetechnical detail (activity / expression / position)only for process-spel
tsmBusinessErrorActivityIdthe failing activity idonly for process-spel
tsmBusinessErrorSourcehow 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.

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 @Transactional that marked the shared transaction rollback-only before the error was caught — the handled branch is then lost, see Known limitations below and Process Transactions.

Use a stable, namespaced, uppercase code that reads as a contract between the code that raises it and the BPMN that catches it:

  • ORDER.INVALID
  • CUSTOMER.NOT_ELIGIBLE
  • PAYMENT.DECLINED

Avoid free-form, localized, or message-derived codes — the match is exact and case-sensitive.

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-only transactions. A rollback-only mark wins over the handler. If a @Transactional service marked the shared transaction rollback-only before the business exception was thrown, the boundary still matches and the handler branch still runs — but the commit then fails with UnexpectedRollbackException and 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 ordinary failedJob incident — so a handled business error still becomes an incident. Keep validation ahead of transactional side effects, and mark rollback-only only when the whole unit of work is meant to be discarded.
  • #bpmnError(). There is no SpEL helper to raise a real BpmnError; that remains an explicit control-flow mechanism for Java delegates and the External Task API. Catching #businessException does not require it.

References