Online edition for BLOGE
0.9.8-RC1· facts verified 2026-09-15 · 中文
Chapter 5 — Branches That Decide
Promise: By the end of this chapter you will know how to route execution down different paths based on a node's output — and you will understand exactly what happens to the paths that are not taken.
Learning Goals
- Write a
branch onblock that routes to one of several target nodes based on a runtime value. - Use
otherwiseto provide a default path when no explicit case matches. - Explain how the engine marks un-chosen target nodes as SKIPPED.
- Distinguish
branch on(control-plane routing) from thewhenexpression (data-plane value selection). - Diagnose common branch mistakes, then use inclusive branching
(
branch mode=inclusive on ...) when multiple matches must run in parallel; explain how that OR-split differs from the default XOR-split.
Prerequisites
Source Examples
| File | What it shows |
|---|---|
ch05/ticket-routing.bloge | Three-way branch with string cases and otherwise |
ch05/loan-approval.bloge | Branch after a multi-stage risk pipeline with fan-out, transform, and otherwise |
boolean.bloge | Minimal boolean branch — true / false |
otherwise.bloge | Minimal otherwise-only branch |
ch05/multi-flag-approval.bloge | Inclusive branching — multiple review tracks run in parallel based on flags |
Why This Matters
Until now every node in your graphs has always executed. Real workflows aren't like that:
- A credit check approves or rejects — you don't run both paths.
- A support ticket is routed to one handler, not all of them.
- A loan decision triggers approval, rejection, or manual review.
In procedural code you'd write if/else blocks. In BLOGE the equivalent is
branch on — a first-class routing decision declared inside the graph, not
buried in operator code. The engine evaluates the condition, activates exactly
one downstream node, and skips the rest.
This keeps routing decisions visible in the graph definition and in studio tooling, instead of hiding them inside an operator's implementation.
Mental Model
Think of a branch as a railroad switch. One track is selected; the other tracks are blocked. The engine:
- Waits for the source node (
classifyPriority) to complete. - Reads the condition field (
.priority) from that node's output. - Tests each case in declaration order until one matches.
- Dispatches the matching target node; marks every other target as
SKIPPED.
If no case matches and there is no otherwise, none of the target nodes
run. That is almost always a bug — which is why you should include otherwise
whenever the set of values is not exhaustively known.
First Working Example
Here is the branch from
ch05/ticket-routing.bloge:
This 63-line graph is kept whole because the branch result depends on the two parallel fetches, their fan-in, and all three declared targets; trimming any of those parts would hide the control path the reader must trace.
graph ticketRouting {
node fetchCustomer : FetchCustomerOperator {
input { customerId = ctx.customerId }
timeout = 3s
retry = { attempts: 2, backoff: 200ms, strategy: exponential }
}
node fetchTicketHistory : FetchTicketHistoryOperator {
input { customerId = ctx.customerId }
timeout = 3s
}
node analyzeSentiment : AnalyzeSentimentOperator {
depends_on = [fetchCustomer, fetchTicketHistory]
input {
customer = fetchCustomer.output
history = fetchTicketHistory.output
message = ctx.message
}
timeout = 5s
retry = { attempts: 1, backoff: 500ms, strategy: exponential }
fallback = { sentiment: "neutral", score: 0.0, keywords: [] }
}
node classifyPriority : ClassifyPriorityOperator {
depends_on = [analyzeSentiment]
input {
customer = fetchCustomer.output
sentiment = analyzeSentiment.output
}
}
branch on classifyPriority.output.priority {
"vip" -> assignVipAgent
"normal" -> assignNormalAgent
otherwise -> autoResolve
}
node assignVipAgent : AssignVipAgentOperator {
depends_on = [classifyPriority]
input {
customerId = fetchCustomer.output.id
priority = "vip"
}
}
node assignNormalAgent : AssignNormalAgentOperator {
depends_on = [classifyPriority]
input {
customerId = fetchCustomer.output.id
priority = "normal"
}
}
node autoResolve : AutoResolveOperator {
depends_on = [classifyPriority]
input {
customerId = fetchCustomer.output.id
keywords = analyzeSentiment.output.keywords
}
}
}
Read it as a story:
- Two parallel fetches — customer profile and ticket history.
- Sentiment analysis fans in from both, with retry and a fallback.
- Priority classification produces
"vip","normal", or something else. - The branch —
branch on classifyPriority.output.priority— picks exactly one handler. If the priority is"vip", onlyassignVipAgentruns. If it's"normal", onlyassignNormalAgent. Anything else falls through toautoResolveviaotherwise.
Key insight: The three handler nodes all declare
depends_on = [classifyPriority], but the branch ensures only one of them actually executes. The others receiveSKIPPEDstatus.
Java API Equivalent
The same branch on written with the fluent Java API:
Graph graph = Graph.builder("ticketRouting")
// ... nodes defined as before ...
.branch("classifyPriority")
.on("priority")
.when(value -> "vip".equals(value), "assignVipAgent")
.when(value -> "normal".equals(value), "assignNormalAgent")
.otherwise("autoResolve")
.build();
Edge.conditional() is the underlying mechanism. Each .when() creates a
conditional edge with a value predicate; .otherwise() creates the default
edge. The engine's branch evaluation logic is identical regardless of whether
the graph was built from DSL or the Java API.
Prove XOR with Two Cases, Not One Happy Path
An exclusive branch makes two promises: exactly one matching target is selected, and the other target does not execute. Testing only the approval path proves half of that contract.
Read the unselected path as a result
| Case | createOrder | rejectOrder | Business meaning |
|---|---|---|---|
approved=true | EXECUTED | SKIPPED | The order was created and rejection code did not run |
approved=false | SKIPPED | EXECUTED | Rejection ran and no order was created |
SKIPPED is not missing data and not a failure. It is positive evidence that
the graph evaluated the branch and excluded that node. The RC1 foundation
probe therefore asserts both assertNodeExecuted("createOrder") and
assertNodeSkipped("rejectOrder").
Keep the first decision narrow
Start with XOR while learning because one input selects one path. Use inclusive branching only when the business statement genuinely allows several actions at once, such as "notify compliance and request extra documents". Use a decision table when multiple independent conditions determine an outcome and the rule set needs coverage analysis. Those are stronger models, not cleverer spellings of a two-way choice.
One mutation makes the boundary visible: change approved=true to false
without changing anything else. If only the selected and skipped statuses swap,
the branch is isolated. If unrelated nodes change, the decision is coupled to
more graph state than the picture admits.
Break It Apart
Anatomy of a branch on block
branch on <sourceNode>.output.<field> {
<value1> -> <targetNode1>
<value2> -> <targetNode2>
otherwise -> <defaultNode>
}
| Part | What it means |
|---|---|
branch on | Keyword pair that opens a conditional routing block |
<sourceNode>.output.<field> | A node output path expression — the runtime value the engine reads to decide |
<value> -> <target> | A case: if the condition equals <value>, dispatch <target> |
otherwise -> <target> | The default case: if no other case matches, dispatch this target |
Case matching rules
The compiler builds a Predicate<Object> for each case using type-lenient
comparison:
| Case literal | How it matches at runtime |
|---|---|
true / false | If the runtime value is a Boolean, direct ==. Otherwise, case-insensitive String.valueOf() comparison. |
"someString" | String equality, or String.valueOf() of the runtime value. |
42 (number) | If the runtime value is a Number, doubleValue() comparison. Otherwise, no match. |
Cases are evaluated in declaration order — the first match wins.
Boolean branches
The simplest branch uses boolean values. From the conformance fixture
boolean.bloge:
graph g {
node a : Op {}
node b : Op {}
node c : Op {}
branch on a.output.approved {
true -> b
false -> c
}
}
If a.output.approved is true, node b runs and c is skipped. If
false, c runs and b is skipped.
The otherwise catch-all
From the conformance fixture
otherwise.bloge:
graph g {
node a : Op {}
node b : Op {}
branch on a.output.status {
otherwise -> b
}
}
An otherwise-only branch means "whatever the value is, always dispatch b."
This is unusual but valid — it can be useful as a placeholder during
prototyping.
What SKIPPED means
When the engine selects one branch target, every other target node is marked
SKIPPED in the NodeStatus enum. A skipped node:
- Never executes its operator.
- Produces no output — any downstream node that references a skipped node's output will not have that data available.
- Appears as
SKIPPEDin theGraphResultand in execution listeners.
Inclusive Branching: When Multiple Paths Should Run
Everything above describes exclusive branching (XOR-split) — the default
behaviour where exactly one matching branch fires and the rest are skipped.
branch on also supports an inclusive mode (OR-split) that lets all
matching branches fire in parallel.
Why you need it
Consider a loan application that must be reviewed along several independent dimensions. After assessing risk flags, the application might simultaneously require a human review, a compliance check, and a pricing review. With an exclusive branch you would have to pick one — but the real workflow needs all of them.
XOR-split vs OR-split at a glance
| Exclusive (XOR-split, default) | Inclusive (OR-split, inclusive) | |
|---|---|---|
| Branches that fire | Exactly one — the first match | All that match, in parallel |
otherwise fires when | No explicit case matches | Zero branches matched |
| Unmatched targets | Marked SKIPPED | Marked SKIPPED |
| DSL keyword | branch on ... { } | branch mode=inclusive on ... { } |
| Java API | .branch().on().when()... | .branch().on().inclusive().when()... |
DSL syntax
Set mode=inclusive before on:
branch mode=inclusive on assessFlags.reviewFlags {
"NEEDS_REVIEW" -> manualReview
"NEEDS_COMPLIANCE" -> complianceReview
"NEEDS_PRICING" -> pricingReview
otherwise -> autoApprove
}
If assessFlags.reviewFlags contains both "NEEDS_REVIEW" and
"NEEDS_PRICING", both manualReview and pricingReview run in
parallel while complianceReview is skipped. The otherwise target
(autoApprove) only runs when none of the cases matched.
Java API
Use the .inclusive() method on BranchBuilder:
graph.branch("assessFlags")
.on("reviewFlags")
.inclusive() // enable OR-split
.when(v -> v instanceof List<?> l
&& l.contains("NEEDS_REVIEW"), "manualReview")
.when(v -> v instanceof List<?> l
&& l.contains("NEEDS_COMPLIANCE"), "complianceReview")
.when(v -> v instanceof List<?> l
&& l.contains("NEEDS_PRICING"), "pricingReview")
.otherwise("autoApprove");
Under the hood this sets the inclusive field on Edge.Conditional to true.
Worked example — multi-flag approval
See
ch05/multi-flag-approval.bloge
for a complete graph. The key flow:
fetchApplicationretrieves the loan application.assessFlagsanalyses risk and produces a list of review flags.- The inclusive branch fans out to every matching review track:
"NEEDS_REVIEW"→manualReview"NEEDS_COMPLIANCE"→complianceReview"NEEDS_PRICING"→pricingReview
- If the flags list is empty,
otherwiseroutes toautoApprove.
Because the branch is inclusive, a single application can trigger two or even all three review tracks simultaneously.
When to use inclusive vs exclusive
| Use exclusive (default) when… | Use inclusive when… |
|---|---|
| Exactly one outcome applies (approve / reject / defer) | Multiple independent outcomes can apply at the same time |
| The condition is a single-valued enum or boolean | The condition is a collection or bitmask of flags |
| You want first-match short-circuit semantics | You want fan-out to all matching downstream nodes |
otherwise behaviour difference
- Exclusive:
otherwisefires when no explicit case matches — it acts as a catch-all default. - Inclusive:
otherwisefires only when zero cases matched. If at least one case matched,otherwiseis skipped — even if some other cases did not match.
When You Need a Node That Always Runs — upstream_policy = ALL_RESOLVED
The default scheduling rule is: a node runs only if at least one upstream completed. That is what you want for ordinary fan-in. But some nodes are deliberately wired downstream of a branch and need to run regardless of which path was taken — for example:
- An outcome recorder that logs which path won
- A cleanup node that runs after both success and failure paths
- A metrics collector that does not care about the business outcome
If you declare depends_on = [branchTargetA, branchTargetB, branchTargetC]
and the branch only picked one path, the others are SKIPPED — and your
recorder is also SKIPPED, because it has zero COMPLETED upstreams.
upstream_policy = ALL_RESOLVED tells the engine: "run me as soon as all
my upstreams have reached a terminal state, including SKIPPED and
CANCELLED." The name reads as "wait for all upstreams to be resolved",
not "wait for all to succeed".
node recordOutcome : RecordOutcomeOperator {
depends_on = [assignVipAgent, assignNormalAgent, autoResolve]
upstream_policy = ALL_RESOLVED
input {
// SKIPPED upstreams have no output — pair with safe navigation
vip = assignVipAgent.output?.agentId ?? null
normal = assignNormalAgent.output?.agentId ?? null
auto = autoResolve.output?.ticketId ?? null
}
}
.node("recordOutcome", new RecordOutcomeOperator())
.dependsOn("assignVipAgent", "assignNormalAgent", "autoResolve")
.upstreamPolicy(UpstreamPolicy.ALL_RESOLVED)
Brain check: what is the difference between
upstream_policy = ALL_RESOLVEDand connectingrecordOutcometo only the path that actually ran? Hint: think about how many edges you have to write, and what happens when you add a fourth branch.
Common Trap
❌ Branching on a non-output expression
// WRONG — branch condition must be a node output path
branch on ctx.priority {
"high" -> fastTrack
otherwise -> normalQueue
}
The compiler requires a node output path (or transform field path) as the condition expression. You cannot branch directly on a context value. If you need to route on context data, introduce a node or transform that surfaces the value as output:
// CORRECT — use a transform to expose the context value
transform routing {
priority = ctx.priority
}
branch on routing.priority {
"high" -> fastTrack
otherwise -> normalQueue
}
❌ Forgetting otherwise with open-ended values
// DANGEROUS — what if decision is "review"?
branch on makeDecision.output.decision {
"approved" -> approveLoan
"rejected" -> rejectLoan
}
If makeDecision returns "review", no case matches and no target node
runs. Always include otherwise when the set of possible values is not
exhaustively known:
// SAFE
branch on makeDecision.output.decision {
"approved" -> approveLoan
"rejected" -> rejectLoan
otherwise -> manualReview
}
❌ Confusing branch on with when
branch on and when share arrow syntax (->) and otherwise, but they are
completely different:
branch on | when | |
|---|---|---|
| Plane | Control — decides which nodes run | Data — decides which value a field takes |
| Where it appears | Graph body, top level | Inside an expression (input binding or transform) |
| Effect | Activates one node, skips others | Produces a single value |
Don't use branch on when you just need to compute a value. Use when
instead.
❌ Confusing inclusive branching with parallel execution
// WRONG assumption — this is still an exclusive branch!
branch on classifyRisk.level {
"HIGH" -> escalate
"MEDIUM" -> review
otherwise -> autoApprove
}
Adding concurrency elsewhere does not make an exclusive branch "run things
in parallel". Without mode=inclusive, only the first matching case fires.
If you need multiple branches to fire simultaneously, you must explicitly add
mode=inclusive to the branch block:
// CORRECT — mode=inclusive enables OR-split
branch mode=inclusive on assessFlags.reviewFlags {
"NEEDS_REVIEW" -> manualReview
"NEEDS_COMPLIANCE" -> complianceReview
otherwise -> autoApprove
}
Also remember: inclusive branching is for cases where the condition value can match multiple cases (e.g., a list of flags). If the condition is a single-valued field like a string enum, inclusive and exclusive will behave identically — prefer exclusive for clarity.
❌ Using branch on as a decision table
branch on is the right tool for routing — deciding which downstream
node runs. It starts to hurt when you use it for classification or
policy lookup, where a set of inputs maps to one output value.
The smell is easy to spot: every branch target exists only to return the same shape of result.
// Awkward — routing nodes just to classify a value
branch on creditScore.output.band {
"platinum" -> assignPlatinumTier
"gold" -> assignGoldTier
otherwise -> rejectApplication
}
That design creates three problems:
- Buried outputs. The result is spread across several node output paths instead of one stable place.
- Weak multi-match semantics.
branch oncan choose one arm or fan out, but it cannot express "collect all matching rules and return them" cleanly. - No policy lint. Tooling cannot warn you that a rule set is incomplete or that two policy rows conflict.
Use a decision_table when the logic is really a rule table:
graph creditScreening {
decision_table credit_tier(score = applicant.output.score) hit=first -> String {
rule (score: score >= 750) -> "platinum"
rule (score: 680 <= score < 750) -> "gold"
otherwise -> "rejected"
}
}
The result is always at credit_tier.output.value, no matter how many rules
you add later.
| Need | Prefer |
|---|---|
| Route execution to one or more downstream nodes | branch on |
| Classify inputs into one value | decision_table hit=first or hit=unique |
| Accept multiple matches only if they agree | decision_table hit=any |
| Return all matching outcomes | decision_table hit=collect |
Full syntax, hit policies, lint rules, and error codes live in
Appendix F — Decision Table. Chapter 9 shows
the decision-table/missing-otherwise and decision-table/collect-otherwise
lint recipes, and Chapter 34 turns this trap into a hands-on lab.
What Goes Wrong
Missing otherwise with an unexpected value
branch on makeDecision.output.decision {
"approved" -> approveLoan
"rejected" -> rejectLoan
}
If makeDecision returns "review", no case matches:
GraphResult {
status: COMPLETED,
nodeStatuses: {
approveLoan: SKIPPED,
rejectLoan: SKIPPED
}
}
The graph completes, but no downstream work happened. Any node that
depended on approveLoan or rejectLoan output will have missing data.
Fix: Add otherwise -> manualReview to ensure at least one branch always
activates.
Guided Rewrite
Look at the full
ch05/loan-approval.bloge
example. It runs four risk checks in parallel, aggregates them, and branches:
branch on makeDecision.output.decision {
"approved" -> approveLoan
"rejected" -> rejectLoan
otherwise -> manualReview
}
Questions to work through:
- How many nodes run before the branch? Count them:
fetchApplication, then four parallel checks (checkCredit,detectFraud,verifyIncome,checkBlacklist), thenaggregateRisk, thenmakeDecision— seven nodes total. - What happens if
makeDecisionreturns"escalated"? Theotherwisecase catches it and routes tomanualReview. - Can
approveLoanandrejectLoanever both run? No — exactly one branch target is selected. - What does the
riskSummarytransform do relative to the branch? It projects upstream outputs for audit logging. Because it doesn't depend onmakeDecision, it can resolve independently — but it has no effect on which branch path is taken.
Now try modifying the graph yourself:
- Add a fourth case:
"deferred" -> deferApplication. - Declare the new
deferApplicationnode with aDeferApplicationOperator, depending onmakeDecision. - Predict: what happens to the other three target nodes when the decision is
"deferred"?
Brain Check
- What keyword pair opens a conditional routing block?
(
branch on.) - What type of expression must follow
branch on? (A node output path expression likenodeId.output.field, or a transform field path liketransformId.field.) - If a branch has three cases and no
otherwise, what happens when none of the cases match? (No target node runs — all targets are skipped.) - Can two branch targets ever execute in the same graph run with a default
(exclusive)
branch on? (No — exactly one target is selected per exclusivebranch onevaluation.) - What is the difference between
branch onandwhen? (branch onis control-plane routing that determines which nodes run.whenis a data-plane expression that determines which value a field takes.) - Design question: You have a graph where two independent features each
need a branch. Can a single graph contain two separate
branch onblocks? If so, how does the engine handle them? (Yes — eachbranch onis evaluated independently when its source node completes. They do not interact. This is common in complex workflows where multiple decisions happen in parallel paths.) - What keyword do you add to
branch onto make it an inclusive (OR-split) branch, and how doesotherwisebehave differently? (Addinclusiveafter the condition expression. In an inclusive branch,otherwisefires only when zero cases matched, whereas in an exclusive branch it fires when no explicit case matches.)
Lab
- Open
ch05/ticket-routing.blogein your editor. - Add a fourth handler node called
escalateToManagerwith anEscalateToManagerOperator. It should depend onclassifyPriorityand takefetchCustomer.output.idandanalyzeSentiment.outputas inputs. - Add a new case to the branch:
"critical" -> escalateToManager. - Draw the execution flow for each possible priority value:
"vip"→ which node runs?"critical"→ which node runs?"normal"→ which node runs?"unknown"→ which node runs? (Hint:otherwise.)
- Bonus: Rewrite the branch to use only
otherwise(no explicit cases). What does this mean for the graph's behaviour? Is it useful?
Experiment acceptance card
- Expected and observed: Two XOR cases swap EXECUTED and SKIPPED.
- Failure and recovery: Make every condition miss; repair the threshold or otherwise path.
- Proof boundary: Proves branch selection for fixed inputs, not every business boundary.
- Exercise contract: Approve and reject cases; change one threshold; deliver a status matrix; stop when each case selects one path.
Recap
branch onis control-plane routing — it decides which downstream nodes execute and which are skipped.- The condition must be a node output path or transform field path expression.
- Cases use type-lenient comparison and are tested in declaration order — the first match wins.
otherwiseis the default case. Always include it when the set of possible values is not exhaustively known.- Un-chosen target nodes are marked
SKIPPED— they never execute and produce no output. branch on(control plane) andwhen(data plane) share syntax style but are semantically different. Usebranch onto route execution; usewhento compute a value.- Inclusive branching (
branch mode=inclusive on ...) is an OR-split: all matching branches fire in parallel. Use it when a condition can match multiple cases simultaneously (e.g., a list of flags).otherwiseonly fires when zero cases matched.
Next Step
In Chapter 6 — Resilience by Design you will learn how to declare timeout, retry, and fallback policies on nodes — keeping your graphs reliable without cluttering your operator code.
Reference Links
- DSL Specification — formal grammar and compilation rules
- Core Architecture —
Edge.Conditional,NodeStatus.SKIPPED, and engine dispatch ch05/ticket-routing.bloge— full example sourcech05/loan-approval.bloge— full example sourceboolean.bloge— conformance fixtureotherwise.bloge— conformance fixturech05/multi-flag-approval.bloge— inclusive branching example
Coding Agent: Open the versioned task guide.