Workflow Design Patterns
Knowing what a node or edge type does is not the same as knowing which one to reach for. This guide collects recurring decisions that come up when translating a real process into a dotBEP workflow diagram, the kind of judgment calls that are easy to get wrong on the first pass and that are better caught before the workflow is built than after.
If you have not read BEP Execution yet, start there. It defines what events, effects, automations, and triggers are. This guide assumes you already know the vocabulary and focuses on when to use each one.
Pattern 1: External state changes that must stay in sync require an automation, not an effect
If a transition needs to update the state of an external system (mark an issue as “in progress” in ACC, update a page status in Notion, post a comment to a ticket), do not model that as an effect.
Effects are fire-and-forget: the engine fires them and does not wait for or react to the result. If the call fails, the workflow instance keeps advancing as if it succeeded, and the external system silently falls out of sync with dotBEP’s record. Nobody is notified.
Model it as an automation node instead. The workflow waits for the actual result before proceeding, and a failed call never gets silently swallowed the way a failed effect does.
Do not add a decision node after the automation just to check whether the call technically succeeded. That used to be the only way to make a failure visible, but it is not anymore: automation failure (the handler threw, or no handler was registered for it) is tracked natively by the engine. A failed attempt is recorded in the instance’s history, and the instance is left parked at the automation node, queryable as automationPending (with a failed-attempts count and the last error). No diagram branch is needed to surface that.
mark_status_in_acc (automation: updates the issue status in ACC)
│ [on success, emits "status-updated" and continues to the next node directly]
▼
(next node)
An automation node has exactly one outgoing edge, and it does not have to point to a decision. Point it straight at whatever comes next in the process. See Pattern 2 for the one case where a decision after an automation is actually warranted.
Reserve effects for actions where staleness is acceptable if they fail: notifications, logging, anything where “this might not have gone through” does not compromise the integrity of the process. A Slack message telling a reviewer their step is ready is a good effect. Updating the authoritative status of a tracked issue in the system it came from is not.
Pattern 2: A decision only branches on what the node immediately before it actually produced
A decision node branches on the payload emitted by the node right before it, nothing else. That payload can only carry what that specific node’s actor or handler actually produced when it completed. Before adding a decision after any node, check that the thing the guard asks about is genuinely present in that payload, not assumed, inferred, or borrowed from someone or something that has not acted yet. This shows up in two common, and easy to get wrong, shapes.
Automation output. An automation’s single outgoing edge does not need a decision behind it (see Pattern 1). Add one only when the automation’s output carries a real branching need for the process, one that exists independently of whether the call technically succeeded, for example a clash-detection automation that returns a severity level and the process genuinely treats high- and low-severity clashes differently downstream. Do not reach for a decision here to check success or failure. That is not a business branch, it is the engine’s native failure handling from Pattern 1, and it happens whether or not a decision follows the automation.
inspect_clash (automation: geometry check, returns { severity: "high" | "low" })
│ [emits "clash-inspected" with { severity }]
▼
decision "How severe is the clash?"
├─ high → escalate_to_director (process: needs Director de Obra involved directly)
└─ low → resolve_clash (process: Coordinador BIM handles it routinely)
If you are ever unsure whether a branch after an automation belongs here, ask whether the branch would still make sense if the automation could never fail. If the answer is no, it is not a business branch, it is failure handling, and it does not belong in the diagram.
Process actor. The same rule applies when the node before the decision is a process node: the actor who fed the decision must be the actor whose judgment the guard is actually evaluating. This breaks when a process node performed by one actor is wired straight into a decision that is really about a different actor’s judgment, for example “Contratista de Obra: Correct the model and publish it to Compartido” feeding directly into “Does the Coordinador BIM approve the correction?” The Contratista completing their correction cannot possibly emit whether the Coordinador BIM approves it. That approval has not happened yet, and it belongs to a person who has not had a node of their own in the diagram.
Wrong:
correct_model_and_publish (process, actor: Contratista de Obra)
▼
decision "Does Coordinador BIM approve the correction?" ← nothing upstream produced this
├─ yes → ...
└─ no → ...
Right:
correct_model_and_publish (process, actor: Contratista de Obra)
▼
review_correction (process, actor: Coordinador BIM, payload: { approved: yes | no })
▼
decision "Does Coordinador BIM approve the correction?" ← reads review_correction's own output
├─ yes → ...
└─ no → ...
The fix is to insert an intermediate process node for the actor whose judgment the decision actually needs, one where that actor performs their own review or approval step and its payload is what the decision reads. Whenever a decision’s guard is really asking “what did person X decide,” person X needs their own node producing that payload immediately before the decision, not a decision bolted onto the end of someone else’s unrelated action.
Pattern 3: No edge out of a decision fed by an automation may point back to that automation
This only applies when Pattern 2 puts a decision node right after an automation. Do not wire any of that decision’s outgoing edges back to the automation that feeds it, on any branch. The rule is structural: if a decision node’s incoming edge comes from an automation node, none of its outgoing edges may target that same automation node. It is broken in a way that is not obvious from the diagram alone.
The engine auto-executes an automation node the moment a transition lands on it, decision included. A direct edge from that decision back to the automation, on any branch, creates a tight, synchronous loop with no backoff, bounded only by an internal safety cap on consecutive automation steps. If the loop runs past that cap, the engine simply stops advancing the instance, stranded on the automation node with no human notified and no event a person could realistically discover and emit to recover it.
This is a narrower concern than it used to be: since automation failure no longer needs a decision branch to be visible (Pattern 1), the branch most likely to tempt a modeler into looping back to the automation, “did it fail? retry,” does not exist by default anymore. But if a genuine business decision (Pattern 2) happens to have a branch that conceptually means “go try that automation again,” the same structural hazard still applies, and the fix is the same: route it to a process node instead, one where a human re-triggers the automation as a side effect of an action they already understand, not back to the automation node directly.
Pattern 4: Reuse effects, actions, and automations across flows instead of duplicating them
If the same real-world step shows up in more than one flow, model it once and reuse that node, not a copy per flow. This applies to effects, process actions, and automations alike. A common case is a review or approval action performed by the same role in more than one process, for example an “interventoría” sign-off action that shows up in both a change-order flow and a non-conformity flow. If it is the same action (same actor, same intent, same payload it needs), declare it once and point both flows at it.
Only duplicate when the two occurrences are genuinely different actions wearing the same name, not the same action showing up twice. The test is the payload and intent, not the label: if one flow’s “interventoría review” needs different input data than the other (different fields, a different decision it is making), they are two distinct actions that happen to share a human-readable name, and forcing them into one node loses information or forces an awkward payload union. If they ask for the same data and mean the same thing, they are one action reached from two places.
This is the same principle already covered for automations reached from more than one origin: for example two different rejection branches that both need to mark an ACC issue as reopened share a single automation node, not a duplicate per origin. That used to require duplicating the automation (and, previously, the decision right after it) once per origin, because a shared node’s retry-on-failure branch had no way to route back to the correct caller. That concern is gone now that failure is handled natively by the engine instead of a diagram branch (Pattern 1): a shared automation node’s one outgoing edge only has to express “where does the happy path continue,” and that is very often the same place regardless of which origin triggered it.
decision_a (rejected) ──┐
├──→ mark_reopened (automation, shared) → (single next node)
decision_b (rejected) ──┘
flow_change_order ──┐
├──→ interventoria_review (process, shared) → (each flow continues from its own next node)
flow_non_conformity ─┘
Only duplicate the node if the happy-path destination genuinely differs per origin in a way that cannot be modeled with a shared next step, or if Pattern 2 applies and the business branching after an automation needs to know which origin triggered it (a guard can only see the automation’s own output, not which edge led into it).
Pattern 5: After changing a workflow, check for orphaned actions, automations, and effects and remove them
Editing a workflow (removing a node, rewiring an edge, replacing one action with another) can leave behind an action, automation, or effect that no diagram node references anymore. Declaring one and reusing it (Pattern 4) only pays off if the reverse also happens: once nothing in any workflow points at it, delete it rather than leaving it declared.
An orphan left in place is not a neutral no-op. It keeps showing up in listings and pickers as if it were still a live part of some process, it can get reused by accident by someone who assumes it is still wired to something, and its presence makes it harder to tell, months later, which declared actions actually matter versus which are dead weight nobody got around to removing.
Make this an explicit last step of any workflow edit, not an afterthought to catch later: after removing or rewiring a node, check whether the action, automation, or effect it used is still referenced by any other node in any workflow. If nothing references it anymore, remove the declaration. Do not skip this because “it might be reused later”; a genuinely reusable step is more valuable to redeclare when the need actually comes up than to keep around unused on the chance it might.
Pattern 6: Consolidate a chain of same-actor process steps into one action and let its description carry the detail
If a workflow has a straight-line sequence of process nodes performed by the same actor, one right after another with no branching, no other actor, and no wait in between, consider modeling it as a single process node instead of one per sub-step. For example, “make the changes in the model,” “upload it to the CDE,” and “share it” performed back to back by the same modeler is one real-world unit of work told across three diagram nodes. Consolidate it into one action, for example “update and share the model in the CDE,” and let the action’s description spell out the sub-steps as prose instead of the diagram spelling them out as separate nodes.
The diagram is not the place to capture every sub-step of what a person does to complete an action; it is the place to capture the process’s structure, meaning who acts, in what order, and where branching or waiting actually happens. A chain of nodes that never branches and never changes actor adds diagram surface (more nodes to wire, more edges to keep correct, more surface for Pattern 5 to have to catch later) without adding any structure the process actually has. The detail is real and worth keeping, it just belongs in the action’s description, not encoded as separate nodes.
Before:
edit_model (process) → upload_to_cde (process) → share_model (process) → (next node)
After:
update_and_share_model_in_cde (process, description: "Make the required changes in the
authoring model, upload the updated version to the CDE, and share it with the project team.")
→ (next node)
Do not consolidate across a genuine branch, a wait for a different actor, or a step that other flows reuse independently (see Pattern 4): if “upload to the CDE” is itself reused elsewhere as its own action, collapsing it into a bigger one duplicates it instead of reusing it. Consolidation only applies when the whole chain is truly one actor doing one uninterrupted piece of work.
Pattern 7: Notify from inside the automation, not from an effect on its edge
If a transition needs to send a notification about something an automation just did (mark an issue in progress and post about it in a chat, close a ticket and announce it), make that call from inside the automation’s own handler. Do not model it as a discord-notify-style effect attached to the automation’s outgoing edge, even though Pattern 1 says effects are the right tool for fire-and-forget notifications in general.
The automation already has everything it needs the moment it runs: it knows exactly what happened, and it can build the message and make the call directly, with no extra indirection. An effect, by contrast, only sees what the instance’s accumulated context happens to expose under the exact key names its own payload declares — which forces the automation’s output (or the triggering event’s payload) to carry a field under that specific name just so an unrelated effect can find it, purely to satisfy the effect’s lookup. Calling out from inside the automation skips that indirection entirely.
Wrong:
mark_in_review (automation: updates status in Bimply)
│ [edge carries effect: notify_discord, payload: { message }]
▼
review_model (process: Coordinador BIM reviews)
Right:
mark_in_review (automation: updates status in Bimply, then posts to Discord itself)
▼
review_model (process: Coordinador BIM reviews)
Effects remain exactly right when the transition is fired by a human completing a process step, not by an automation. There is no handler running at that point to hang a direct call off of — the effect is the only mechanism available to react to it.
review_model (process, actor: Coordinador BIM, payload: { approved, comments })
│ [edge carries effect: notify_discord, payload: { comments }]
▼
(next node)
This does not change Pattern 1: whether a state change needs to be an automation is still decided by whether the external system must stay in sync. This pattern only decides, once something is already an automation, where the notification about what it did should live.