Skip to main content

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

  1. Write a branch on block that routes to one of several target nodes based on a runtime value.
  2. Use otherwise to provide a default path when no explicit case matches.
  3. Explain how the engine marks un-chosen target nodes as SKIPPED.
  4. Distinguish branch on (control-plane routing) from the when expression (data-plane value selection).
  5. 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

FileWhat it shows
ch05/ticket-routing.blogeThree-way branch with string cases and otherwise
ch05/loan-approval.blogeBranch after a multi-stage risk pipeline with fan-out, transform, and otherwise
boolean.blogeMinimal boolean branch — true / false
otherwise.blogeMinimal otherwise-only branch
ch05/multi-flag-approval.blogeInclusive 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

Diagram: 05-branches-that-decide figure 1

Think of a branch as a railroad switch. One track is selected; the other tracks are blocked. The engine:

  1. Waits for the source node (classifyPriority) to complete.
  2. Reads the condition field (.priority) from that node's output.
  3. Tests each case in declaration order until one matches.
  4. 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:

  1. Two parallel fetches — customer profile and ticket history.
  2. Sentiment analysis fans in from both, with retry and a fallback.
  3. Priority classification produces "vip", "normal", or something else.
  4. The branchbranch on classifyPriority.output.priority — picks exactly one handler. If the priority is "vip", only assignVipAgent runs. If it's "normal", only assignNormalAgent. Anything else falls through to autoResolve via otherwise.

Key insight: The three handler nodes all declare depends_on = [classifyPriority], but the branch ensures only one of them actually executes. The others receive SKIPPED status.

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.

Diagram: XOR case and node-status matrix

Read the unselected path as a result

CasecreateOrderrejectOrderBusiness meaning
approved=trueEXECUTEDSKIPPEDThe order was created and rejection code did not run
approved=falseSKIPPEDEXECUTEDRejection 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>
}
PartWhat it means
branch onKeyword 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 literalHow it matches at runtime
true / falseIf 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 SKIPPED in the GraphResult and 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 fireExactly one — the first matchAll that match, in parallel
otherwise fires whenNo explicit case matchesZero branches matched
Unmatched targetsMarked SKIPPEDMarked SKIPPED
DSL keywordbranch 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:

  1. fetchApplication retrieves the loan application.
  2. assessFlags analyses risk and produces a list of review flags.
  3. The inclusive branch fans out to every matching review track:
    • "NEEDS_REVIEW"manualReview
    • "NEEDS_COMPLIANCE"complianceReview
    • "NEEDS_PRICING"pricingReview
  4. If the flags list is empty, otherwise routes to autoApprove.

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 booleanThe condition is a collection or bitmask of flags
You want first-match short-circuit semanticsYou want fan-out to all matching downstream nodes

otherwise behaviour difference

  • Exclusive: otherwise fires when no explicit case matches — it acts as a catch-all default.
  • Inclusive: otherwise fires only when zero cases matched. If at least one case matched, otherwise is 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_RESOLVED and connecting recordOutcome to 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 onwhen
PlaneControl — decides which nodes runData — decides which value a field takes
Where it appearsGraph body, top levelInside an expression (input binding or transform)
EffectActivates one node, skips othersProduces 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:

  1. Buried outputs. The result is spread across several node output paths instead of one stable place.
  2. Weak multi-match semantics. branch on can choose one arm or fan out, but it cannot express "collect all matching rules and return them" cleanly.
  3. 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.

NeedPrefer
Route execution to one or more downstream nodesbranch on
Classify inputs into one valuedecision_table hit=first or hit=unique
Accept multiple matches only if they agreedecision_table hit=any
Return all matching outcomesdecision_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:

  1. How many nodes run before the branch? Count them: fetchApplication, then four parallel checks (checkCredit, detectFraud, verifyIncome, checkBlacklist), then aggregateRisk, then makeDecision — seven nodes total.
  2. What happens if makeDecision returns "escalated"? The otherwise case catches it and routes to manualReview.
  3. Can approveLoan and rejectLoan ever both run? No — exactly one branch target is selected.
  4. What does the riskSummary transform do relative to the branch? It projects upstream outputs for audit logging. Because it doesn't depend on makeDecision, 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 deferApplication node with a DeferApplicationOperator, depending on makeDecision.
  • Predict: what happens to the other three target nodes when the decision is "deferred"?

Brain Check

  1. What keyword pair opens a conditional routing block? (branch on.)
  2. What type of expression must follow branch on? (A node output path expression like nodeId.output.field, or a transform field path like transformId.field.)
  3. 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.)
  4. Can two branch targets ever execute in the same graph run with a default (exclusive) branch on? (No — exactly one target is selected per exclusive branch on evaluation.)
  5. What is the difference between branch on and when? (branch on is control-plane routing that determines which nodes run. when is a data-plane expression that determines which value a field takes.)
  6. Design question: You have a graph where two independent features each need a branch. Can a single graph contain two separate branch on blocks? If so, how does the engine handle them? (Yes — each branch on is evaluated independently when its source node completes. They do not interact. This is common in complex workflows where multiple decisions happen in parallel paths.)
  7. What keyword do you add to branch on to make it an inclusive (OR-split) branch, and how does otherwise behave differently? (Add inclusive after the condition expression. In an inclusive branch, otherwise fires only when zero cases matched, whereas in an exclusive branch it fires when no explicit case matches.)

Lab

  1. Open ch05/ticket-routing.bloge in your editor.
  2. Add a fourth handler node called escalateToManager with an EscalateToManagerOperator. It should depend on classifyPriority and take fetchCustomer.output.id and analyzeSentiment.output as inputs.
  3. Add a new case to the branch: "critical" -> escalateToManager.
  4. 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.)
  5. 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 on is 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.
  • otherwise is 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) and when (data plane) share syntax style but are semantically different. Use branch on to route execution; use when to 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). otherwise only 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.


Coding Agent: Open the versioned task guide.