Drawing UML activity diagrams
Open in the interactive app: practice, quiz, mock interview →In one minute
- An activity diagram models a process as actions connected by control flows: what happens, in what order, under which conditions, and what runs in parallel. It is the UML descendant of the flowchart, with concurrency built in.
- Decision and merge diamonds choose one path; fork and join bars split into concurrent paths and wait for all of them. Confusing merge with join is the classic bug: a merge passes every token through, a join waits for one on every input.
- UML defines the semantics as tokens moving along edges, borrowed from Petri nets. Reasoning in tokens explains deadlocks (a join waiting for a token that will never come) and duplicated work (a merge letting two tokens through).
- Swimlanes (partitions) show who performs each action, which makes handoffs between teams or services visible.
- The same model runs in production as BPMN in Camunda, a
Parallelstate in AWS Step Functions, an Airflow DAG, orPromise.allanderrgroupin code.
How it works
The nodes
An order fulfilment process at a warehouse makes a good example. It starts at the initial node (a filled circle). Actions are rounded rectangles: Receive order, Charge card, Pick items, Ship. Control flows are arrows between them.
A decision (diamond, one input) picks exactly one outgoing edge, each labelled with a guard such as [approved] or [else]. A merge (diamond, several inputs) rejoins alternative paths without waiting. Both use the same shape, so label them.
A fork (thick bar, one input) starts concurrent flows: after Receive order, charging and picking run in parallel. A join (thick bar, several inputs) waits until every incoming flow has arrived.
There are two ends. The activity final (bullseye) stops the whole activity, including any flows still running elsewhere. The flow final (circle with an X) ends only the flow that reaches it. Using an activity final at the end of one branch of a fork silently kills the other branches, which is sometimes intended (cancel everything on fraud) and often not.
Tokens
The UML specification defines activity semantics as token flow. The initial node places a token on its outgoing edge. An action fires when a token arrives, and when it completes it offers a token on its outgoing edges. A decision routes its token down one edge; a fork copies it onto every edge; a merge passes each token through as it arrives; a join waits until it holds one token from each incoming edge, then emits one.
This is the Petri net model, where places hold tokens and transitions consume and produce them. Questions such as "can it deadlock?" or "can an action run twice for one order?" become questions about where tokens can end up.
Two bugs follow directly. If a decision inside one fork branch sends its token somewhere other than the join, the join waits forever: a deadlock. If two fork branches end at a merge instead of a join, the action after the merge runs twice.
Swimlanes, objects and signals
Partitions split the diagram into lanes, one per actor, team or service: Customer, Payments, Warehouse. Every arrow that crosses a lane is a handoff, and handoffs are where delays, lost messages and blame live.
Object nodes show data passed between actions, such as an Invoice. Send signal and accept event actions show asynchronous messages and waits, an hourglass shows a timer, and an interruptible region (dashed box with a lightning-bolt edge) cancels everything inside when an event such as "Order cancelled" arrives.
Choosing activity over sequence
Use an activity diagram for control flow: branches, loops, parallelism and who does what. Use a sequence diagram for which component calls which, in what order. A checkout often needs both.
Drawing it in Mermaid
Mermaid has no activity diagram type, so a flowchart stands in: diamonds {"..."} for decisions, labelled edges for guards, and plain nodes named "fork" and "join" for the bars. PlantUML's activity syntax has fork, fork again and end fork keywords, swimlanes with |Lane|, and kill and detach for the two finals.
Reviewing agent-written workflows
Agents generating workflow definitions or Promise.all code make the token bugs above: a decision in a parallel branch that skips the join, a merge where a join was needed, an early return that leaves other branches running. Ask where every token goes on every path, or run the model through an executor like the practice task's.
The same shape elsewhere
Fork and join are the fan-out shape: scatter, then gather under a policy (all, any, quorum). A search engine querying 50 shards and a quorum write waiting for two of three replicas face the same choices, and the join policy and straggler handling matter more than the fork.
Down the stack
Layer 1The contract
A well-formed activity promises: an action runs once per token it receives; a decision emits exactly one token, so its guards must cover every case (an [else] edge guarantees it); a join emits only after receiving a token on every incoming edge; an activity final terminates all flows immediately, and a flow final consumes one token and nothing else. The activity is complete when a token reaches an activity final, or when no tokens remain. If tokens remain and nothing can fire, the activity is deadlocked.
Layer 2The mechanism
Workflow engines implement this contract directly. BPMN 2.0, the process notation from the same standards body as UML, has an exclusive gateway (decision), a parallel gateway (fork when it has several outputs, join when it has several inputs) and an inclusive gateway (fire some outputs, then wait only for those). Camunda's Zeebe engine records, per process instance, which incoming sequence flows of a joining parallel gateway have been taken, and activates the gateway when all of them have.
AWS Step Functions models fork and join as a Parallel state with a Branches array. It starts every branch, waits for all of them, and passes on an array of their outputs; if any branch fails, the whole state fails and the other branches are stopped, unless a Catch handles it. Airflow expresses joins as trigger rules on a task: all_success (the default), all_done, one_success, none_failed. Temporal leaves it to code: start activities as futures, then wait on all of them.
In code, a Go fork is go f() and a join is sync.WaitGroup.Wait or errgroup.Group.Wait, which also cancels a shared context on the first error. Promise.all is a fail-fast join and Promise.allSettled waits for all. Java's ForkJoinPool adds work-stealing deques per worker thread.
Layer 3The machine
In-process forks are cheap. A goroutine starts with a 2 KB stack and is created in well under a microsecond; an OS thread reserves megabytes of virtual stack and takes tens of microseconds to create, which is why thread pools exist. A join on a WaitGroup is an atomic counter decrement per branch plus, for the waiter, a futex sleep and wakeup costing a few microseconds.
Durable engines persist every token move so the process survives crashes. Step Functions Standard workflows bill each state transition (on the order of $25 per million), Zeebe appends each move to a replicated log, and Temporal caps a workflow's event history, so long loops use continue-as-new. At 50 transitions per instance and 2 million instances a month, that is 100 million transitions, a real line item.
Why it's built this way
- Business processes branch, run in parallel and span teams, so the notation needs decisions, forks and lanes, not just sequence.
- Pictures of concurrency are easy to misread, so UML gives them precise token semantics borrowed from Petri nets.
- Precise semantics make the diagram executable, so BPMN engines and workflow services run the model itself.
- Long-running processes must survive crashes, so engines persist every token move, which makes each transition cost money and latency.
Diagram
flowchart TD
I(("start")) --> R["Receive order"]
R --> F["fork"]
F --> C["Charge card"]
F --> P["Pick items"]
C --> D{"approved?"}
D -- "[yes]" --> J["join"]
D -- "[else]" --> X["Cancel order"]
X --> AF(("activity final"))
P --> J
J --> S["Ship"]
S --> E(("end"))Worked example
A retailer runs order fulfilment on a workflow engine at 40,000 orders a day. The diagram in the design doc forks after Receive order into Charge card and Pick items, with a join before Ship. Declined cards go to Cancel order, which the team drew ending in a flow final, because "only the payment branch is cancelled".
About 3 percent of cards are declined, 1,200 orders a day. In each, the payment branch's token is consumed by the flow final and the picking branch's token waits at the join forever. The engine shows 1,200 new instances a day stuck "active". Each holds a picked tote on a shelf, and after a week 8,400 totes block the packing area. Nobody gets an error, because a waiting join is a legal state.
An activity final would stop the picking branch but could leave it half done. The team instead routed Cancel order into an interrupting event that also runs Return items to stock, and added a review rule: every fork branch must reach the join, an activity final, or a compensating interrupt. A token-flow executor in CI now runs each process definition under every guard combination, which takes milliseconds.
Trade-offs
| Option | Strengths | Weaknesses | Choose when |
|---|---|---|---|
| UML activity diagram | Readable, precise token semantics | Not executable by itself | Design discussion, documenting processes |
| BPMN on an engine (Camunda, Zeebe) | The diagram is the program, auditable | Engine to operate, per-transition cost | Long-running business processes with human steps |
| Step Functions or Airflow definitions | Managed, durable, retries built in | JSON or Python DSL hides the picture | Cloud workflows, data pipelines |
| Workflow as code (Temporal) | Full language, testable | Diagram must be drawn separately | Complex logic, developer-owned processes |
Plain code (errgroup, Promise.all) | Fast, no infrastructure | Lost on crash, no audit trail | Short in-process concurrency |
Pitfalls in production
- A decision inside a fork branch routes away from the join, leaving instances waiting forever. Fix: every branch reaches the join, an activity final, or a compensating interrupt; check it mechanically.
- Two parallel branches converge on a merge, so the next action runs twice (two shipments, two emails). Fix: use a join, and make the downstream action idempotent anyway.
- An activity final at the end of one branch kills in-flight work in the others, such as a half-written ledger entry. Fix: use flow finals in branches and compensate explicitly.
- Guards that do not cover all cases leave tokens stuck at a decision. Fix: always include
[else], and test every guard combination. - Fan-out without a bound: a fork over a 50,000-item list starts 50,000 parallel branches and hits engine or API limits. Fix: batch, or use a bounded map with a concurrency limit, such as the
MaxConcurrencyfield on a Step FunctionsMapstate.
Leadership lens
- Ask, for every fork in a design, what the join policy is (all, any, quorum), what happens to stragglers, and how a failure in one branch reaches the others.
- Choose between an engine and plain code by duration and audit needs: minutes and in-process means code; days, humans or regulators means a durable engine with a diagram people can read.
- Make "stuck instances older than N hours" a standard alert on every workflow engine; deadlocked joins are silent otherwise.
Check yourself
Two parallel branches end at a diamond with two inputs and one output. Is that a merge or a join, and what goes wrong?
Show answer
A diamond is a merge, so it passes both tokens through and the next action runs twice. A join bar is needed to wait for both and emit one token.
Why does a flow final in one branch of a fork usually cause a deadlock?
Show answer
It consumes that branch's token, so the join never receives a token on that edge and waits forever.
How does an activity final differ from a flow final when other branches are still running?
Show answer
The activity final terminates the whole activity, including the other branches; a flow final ends only the token that reached it.
Which AWS Step Functions behaviour corresponds to a join with fail-fast semantics?
Show answer
The
Parallelstate: it waits for all branches, and if one fails the state fails and the remaining branches are stopped unless caught.
Watch
- UML 2.0 Activity Diagrams (Derek Banas): the notation, including forks, joins, swimlanes and signals, with worked examples.
Free resources
- Activity diagram (Wikipedia): notation summary and relationship to Petri nets.
- Petri net (Wikipedia): the token model behind activity semantics, with reachability and deadlock analysis.
- Activity diagram (PlantUML): fork, join, swimlanes and finals as diagram-as-code.
- Parallel gateways (Camunda): how a BPMN engine forks and joins tokens.
- Parallel workflow state (AWS Step Functions): fork and join with fail-fast semantics in a managed service.
- Business Process Model and Notation (Wikipedia): gateways, events and how BPMN relates to UML activities.