Skip to content

Commit 9f5a720

Browse files
committed
feat: saga pattern
1 parent a571b61 commit 9f5a720

File tree

20 files changed

+3144
-231
lines changed

20 files changed

+3144
-231
lines changed

guides/branching.md

Lines changed: 371 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,371 @@
1+
# Conditional Branching
2+
3+
Execute different steps based on runtime conditions.
4+
5+
## Basic Usage
6+
7+
The `branch` macro evaluates a condition and executes only the matching clause:
8+
9+
```elixir
10+
defmodule MyApp.DocumentProcessor do
11+
use Durable
12+
use Durable.Context
13+
14+
workflow "process_document" do
15+
step :classify do
16+
doc_type = AI.classify(input()["content"])
17+
put_context(:doc_type, doc_type)
18+
end
19+
20+
branch on: get_context(:doc_type) do
21+
:invoice ->
22+
step :process_invoice do
23+
extract_invoice_data()
24+
end
25+
26+
:contract ->
27+
step :process_contract do
28+
extract_contract_data()
29+
end
30+
31+
_ ->
32+
step :manual_review do
33+
put_context(:needs_review, true)
34+
end
35+
end
36+
37+
# Runs after ANY branch completes
38+
step :save do
39+
save_to_database()
40+
end
41+
end
42+
end
43+
```
44+
45+
## Pattern Matching
46+
47+
The `on:` expression is evaluated at runtime, and the result is matched against clause patterns.
48+
49+
### Supported Patterns
50+
51+
| Pattern | Example |
52+
|---------|---------|
53+
| Atoms | `:invoice`, `:pending`, `:active` |
54+
| Strings | `"pdf"`, `"high"` |
55+
| Integers | `1`, `2`, `100` |
56+
| Booleans | `true`, `false` |
57+
| Default | `_` (matches anything) |
58+
59+
```elixir
60+
# Matching atoms
61+
branch on: get_context(:status) do
62+
:active -> step :handle_active do ... end
63+
:pending -> step :handle_pending do ... end
64+
_ -> step :handle_other do ... end
65+
end
66+
67+
# Matching strings
68+
branch on: input()["format"] do
69+
"pdf" -> step :process_pdf do ... end
70+
"docx" -> step :process_docx do ... end
71+
_ -> step :unsupported do ... end
72+
end
73+
74+
# Matching booleans
75+
branch on: get_context(:is_premium) do
76+
true -> step :premium_flow do ... end
77+
false -> step :standard_flow do ... end
78+
end
79+
80+
# Matching integers
81+
branch on: get_context(:tier) do
82+
1 -> step :tier_one do ... end
83+
2 -> step :tier_two do ... end
84+
3 -> step :tier_three do ... end
85+
end
86+
```
87+
88+
## Multiple Steps per Branch
89+
90+
Each branch can contain multiple steps that execute sequentially:
91+
92+
```elixir
93+
branch on: get_context(:order_type) do
94+
:subscription ->
95+
step :validate_subscription do
96+
validate_recurring_payment()
97+
end
98+
99+
step :setup_billing do
100+
create_subscription_billing()
101+
end
102+
103+
step :schedule_renewals do
104+
schedule_monthly_charge()
105+
end
106+
107+
:one_time ->
108+
step :process_payment do
109+
charge_once()
110+
end
111+
end
112+
```
113+
114+
## Default Clause
115+
116+
The `_` pattern matches any value not matched by other clauses:
117+
118+
```elixir
119+
branch on: get_context(:priority) do
120+
:critical ->
121+
step :alert_oncall do
122+
PagerDuty.alert()
123+
end
124+
125+
:high ->
126+
step :create_urgent_ticket do
127+
Tickets.create(priority: :high)
128+
end
129+
130+
_ ->
131+
# Matches :medium, :low, or any other value
132+
step :create_normal_ticket do
133+
Tickets.create(priority: :normal)
134+
end
135+
end
136+
```
137+
138+
## Examples
139+
140+
### Order Processing by Type
141+
142+
```elixir
143+
workflow "process_order" do
144+
step :load_order do
145+
order = Orders.get(input()["order_id"])
146+
put_context(:order, order)
147+
put_context(:order_type, order.type)
148+
end
149+
150+
branch on: get_context(:order_type) do
151+
:digital ->
152+
step :generate_download_link do
153+
link = Downloads.create(get_context(:order))
154+
put_context(:delivery_method, :download)
155+
put_context(:download_link, link)
156+
end
157+
158+
:physical ->
159+
step :create_shipment do
160+
shipment = Shipping.create(get_context(:order))
161+
put_context(:delivery_method, :shipping)
162+
put_context(:tracking_number, shipment.tracking)
163+
end
164+
165+
step :notify_warehouse do
166+
Warehouse.queue_pick(get_context(:order))
167+
end
168+
169+
:service ->
170+
step :schedule_appointment do
171+
slot = Calendar.book(get_context(:order))
172+
put_context(:delivery_method, :appointment)
173+
put_context(:appointment, slot)
174+
end
175+
end
176+
177+
step :send_confirmation do
178+
Email.send_order_confirmation(
179+
get_context(:order),
180+
get_context(:delivery_method)
181+
)
182+
end
183+
end
184+
```
185+
186+
### User Verification Flow
187+
188+
```elixir
189+
workflow "verify_user" do
190+
step :check_verification_status do
191+
user = Users.get(input()["user_id"])
192+
put_context(:user, user)
193+
put_context(:verified, user.email_verified and user.phone_verified)
194+
put_context(:verification_method, user.preferred_verification)
195+
end
196+
197+
branch on: get_context(:verified) do
198+
true ->
199+
step :already_verified do
200+
put_context(:result, :already_verified)
201+
end
202+
203+
false ->
204+
# Nested branch for verification method
205+
branch on: get_context(:verification_method) do
206+
:email ->
207+
step :send_email_code do
208+
code = generate_code()
209+
Email.send_verification(get_context(:user).email, code)
210+
put_context(:pending_verification, :email)
211+
end
212+
213+
:sms ->
214+
step :send_sms_code do
215+
code = generate_code()
216+
SMS.send(get_context(:user).phone, code)
217+
put_context(:pending_verification, :sms)
218+
end
219+
220+
_ ->
221+
step :require_manual_verification do
222+
Support.create_verification_ticket(get_context(:user))
223+
put_context(:pending_verification, :manual)
224+
end
225+
end
226+
end
227+
end
228+
```
229+
230+
### Amount-Based Approval Routing
231+
232+
```elixir
233+
workflow "expense_routing" do
234+
step :load_expense do
235+
expense = Expenses.get(input()["expense_id"])
236+
put_context(:expense, expense)
237+
put_context(:amount, expense.amount)
238+
end
239+
240+
step :determine_tier do
241+
amount = get_context(:amount)
242+
243+
tier = cond do
244+
amount > 10000 -> :executive
245+
amount > 1000 -> :manager
246+
amount > 100 -> :team_lead
247+
true -> :auto
248+
end
249+
250+
put_context(:approval_tier, tier)
251+
end
252+
253+
branch on: get_context(:approval_tier) do
254+
:executive ->
255+
step :cfo_approval do
256+
request_approval(:cfo, get_context(:expense))
257+
end
258+
259+
step :ceo_approval do
260+
request_approval(:ceo, get_context(:expense))
261+
end
262+
263+
:manager ->
264+
step :manager_approval do
265+
request_approval(:manager, get_context(:expense))
266+
end
267+
268+
:team_lead ->
269+
step :team_lead_approval do
270+
request_approval(:team_lead, get_context(:expense))
271+
end
272+
273+
:auto ->
274+
step :auto_approve do
275+
Expenses.approve(get_context(:expense), approver: :system)
276+
end
277+
end
278+
end
279+
```
280+
281+
## Branch vs Decision
282+
283+
Durable provides two ways to control flow:
284+
285+
| Feature | `branch` | `decision` |
286+
|---------|----------|------------|
287+
| Use case | Execute different step groups | Jump to a specific step |
288+
| Syntax | Pattern matching clauses | Return `{:goto, :step}` |
289+
| Multiple steps | Yes, per clause | No, single jump target |
290+
| Readability | High, reads top-to-bottom | Lower, requires tracing jumps |
291+
292+
**Use `branch` when:**
293+
- You have distinct paths with different steps
294+
- Each path may have multiple steps
295+
- You want readable, maintainable code
296+
297+
**Use `decision` when:**
298+
- You need to skip certain steps
299+
- You have simple conditional jumps
300+
- The workflow is linear with occasional skips
301+
302+
```elixir
303+
# Prefer branch for distinct paths
304+
branch on: get_context(:type) do
305+
:a -> step :handle_a do ... end
306+
:b -> step :handle_b do ... end
307+
end
308+
309+
# Decision for simple skips
310+
decision :check_skip do
311+
if get_context(:skip_optional) do
312+
{:goto, :final_step}
313+
else
314+
{:continue}
315+
end
316+
end
317+
318+
step :optional_step do ... end
319+
step :final_step do ... end
320+
```
321+
322+
## Best Practices
323+
324+
### Always Include a Default Clause
325+
326+
```elixir
327+
branch on: get_context(:status) do
328+
:active -> step :handle_active do ... end
329+
:pending -> step :handle_pending do ... end
330+
_ ->
331+
# Handle unexpected values gracefully
332+
step :handle_unknown do
333+
Logger.warn("Unknown status: #{get_context(:status)}")
334+
put_context(:error, :unknown_status)
335+
end
336+
end
337+
```
338+
339+
### Keep Branches Focused
340+
341+
```elixir
342+
# Good - each branch does one thing
343+
branch on: get_context(:payment_method) do
344+
:card -> step :charge_card do ... end
345+
:bank -> step :initiate_transfer do ... end
346+
:crypto -> step :process_crypto do ... end
347+
end
348+
349+
# Avoid - too much logic in branches
350+
branch on: get_context(:type) do
351+
:a ->
352+
step :step1 do ... end
353+
step :step2 do ... end
354+
step :step3 do ... end
355+
step :step4 do ... end
356+
step :step5 do ... end
357+
# Consider extracting to separate workflow
358+
end
359+
```
360+
361+
### Use Descriptive Context Keys
362+
363+
```elixir
364+
# Good
365+
put_context(:document_type, :invoice)
366+
branch on: get_context(:document_type) do ... end
367+
368+
# Avoid
369+
put_context(:t, :i)
370+
branch on: get_context(:t) do ... end
371+
```

0 commit comments

Comments
 (0)