Online edition for BLOGE
0.9.8-RC1· facts verified 2026-09-15 · 中文
Chapter 10 — Reuse with Subgraphs
Promise: By the end of this chapter you will know how to extract a pipeline into a named subgraph, embed it inside a parent graph with
subgraph("name"), control scope isolation, and recognise when a dynamic subgraph is the right tool instead.
Learning Goals
- Explain what a subgraph is in BLOGE and why it exists.
- Register a pre-built
GraphwithDslCompiler.registerSubGraph()and reference it in DSL using thenode x : subgraph("name") {}syntax. - Distinguish isolated scope (the default for subgraphs) from parent scope, and predict what each mode makes visible inside the child graph.
- Read subgraph output — understand that terminal-node outputs are aggregated into the parent node's result map.
- Recognise when to use the
dynamicSubGraphoperator for runtime-generated DSL instead of compile-time subgraph registration.
Prerequisites
Source Examples
| File | What it shows |
|---|---|
loan-approval-subgraph.bloge | Two parallel subgraphs (credit + compliance) feeding an underwriting decision with a branch |
international-shipment.bloge | Parallel customs-clearance and route-optimisation subgraphs with fan-in |
smart-ticket-handling.bloge | Sentiment-analysis subgraph feeding a priority branch into an escalation subgraph |
order-full-pipeline.bloge | Payment and inventory subgraphs running in parallel |
dynamic-agent.bloge | Runtime DSL generation + dynamicSubGraph execution |
LoanApprovalSubGraphDslExample.java | Java wiring: builds sub-graphs via Graph.builder(), registers them with DslCompiler.registerSubGraph() |
SubGraphOperator.java | Engine-level implementation that executes a child graph and aggregates terminal outputs |
Why This Matters
By Chapter 8 you can write a complete graph in DSL. But real systems have pipelines that recur across domains:
- A credit-assessment pipeline (credit query → income verification → debt ratio → risk scoring) appears in loan approval, mortgage processing, and credit-card issuance.
- A compliance-check pipeline (AML screening → KYC → sanctions → compliance determination) is required by multiple regulatory workflows.
- A kitchen dispatch or delivery coordination pipeline is shared across restaurant order types.
Without subgraphs, you would copy-paste entire node chains into every parent graph, duplicating resilience config, dependencies, and operator wiring. When the compliance rules change, you update N copies — and inevitably miss one.
BLOGE solves this with named subgraphs: you build a pipeline once as a
Graph, register it under a name, and reference it from any parent graph with
node x : subgraph("name") {}. The engine runs the child graph as a nested
execution, injects the parent's input {} bindings as the child's context, and
aggregates the child's terminal-node outputs back into the parent node's result.
One pipeline. One registration. Many call sites.
Mental Model
Think of each subgraph box as a function call: the parent passes arguments
via input {}, the child runs its internal DAG, and the parent receives the
terminal nodes' outputs as a map keyed by node ID.
Key rules:
- Input flows in through
input {}. The bindings are injected as the child'sGraphContext. The child graph reads them withctx.*expressions. - Output flows out through terminal nodes. The
SubGraphOperatoriteratessubGraph.terminalNodes()and collects each terminal's output into aMap<String, Object>. The parent references them assubgraphNode.output.terminalNodeId. - Scope defaults to
isolated. The child does not see parent node outputs or inherit the parentGraphContextunless you explicitly setscope = parent.
First Working Example
Here is
loan-approval-subgraph.bloge,
trimmed to the subgraph wiring:
The 71-line wiring is kept in one block because both parallel child contracts, their parent fan-in, and the two terminal routes must be visible together; a shorter fragment would conceal the parent-visible output boundary.
graph loanApprovalPipeline {
node receiveApplication : ReceiveApplicationOperator {
input {
applicationId = ctx.applicationId
applicantName = ctx.applicantName
requestedAmount = ctx.requestedAmount
termMonths = ctx.termMonths
employerId = ctx.employerId
}
timeout = 3s
}
/// Runs credit assessment sub-graph:
/// creditQuery → incomeVerification → debtRatioCalc → riskScoring
node creditAssessment : subgraph("credit-assessment") {
depends_on = [receiveApplication]
input {
applicationId = receiveApplication.output.applicationId
applicantName = receiveApplication.output.applicantName
requestedAmount = receiveApplication.output.requestedAmount
employerId = receiveApplication.output.employerId
}
timeout = 30s
}
/// Runs compliance check sub-graph:
/// amlScreening → kycVerification → sanctionListCheck → complianceDetermination
node complianceCheck : subgraph("compliance-check") {
depends_on = [receiveApplication]
input {
applicationId = receiveApplication.output.applicationId
applicantName = receiveApplication.output.applicantName
}
timeout = 30s
}
node underwritingDecision : UnderwritingDecisionOperator {
depends_on = [creditAssessment, complianceCheck]
input {
applicationId = receiveApplication.output.applicationId
credit = creditAssessment.output.riskScoring
compliance = complianceCheck.output.complianceDetermination
}
}
branch on underwritingDecision.output.decision {
"approved" -> generateApprovalLetter
otherwise -> generateRejectionNotice
}
node generateApprovalLetter : GenerateApprovalLetterOperator {
depends_on = [underwritingDecision]
input {
applicationId = underwritingDecision.output.applicationId
applicantName = ctx.applicantName
requestedAmount = ctx.requestedAmount
approvedRate = underwritingDecision.output.approvedRate
termMonths = ctx.termMonths
}
}
node generateRejectionNotice : GenerateRejectionNoticeOperator {
depends_on = [underwritingDecision]
input {
applicationId = underwritingDecision.output.applicationId
applicantName = ctx.applicantName
reason = underwritingDecision.output.reason
}
}
}
Read it as a story:
receiveApplicationvalidates the incoming loan request.- Two subgraphs run in parallel —
creditAssessmentandcomplianceCheck— each wrapping a four-node pipeline. Both depend onreceiveApplication, so the engine schedules them concurrently. underwritingDecisionfans in from both subgraphs. It readscreditAssessment.output.riskScoring— the terminal node's output from the credit subgraph — andcomplianceCheck.output.complianceDetermination.- The branch routes to approval or rejection.
Key insight: Each
subgraph("name")node looks like a regular node in the parent's DAG. The engine doesn't care that it contains four internal steps — it sees one node with dependencies and an output map.
One Child Graph, One Parent-Visible Result
In the loan pipeline, creditAssessment contains several internal steps:
credit query, income verification, debt-ratio calculation, and risk scoring.
The parent graph should not bind to all four workspaces. It needs one stable
credit-assessment result.
Read the boundary from the outside in
| Viewpoint | May depend on | Should not depend on |
|---|---|---|
| Child internals | Intermediate outputs such as income and debt ratio | Parent node names or parent scheduling |
| Child terminal contract | {score, grade, reasons} | Internal node layout |
Parent underwritingDecision | creditAssessment.output | creditQuery.output inside the child |
The companion smoke scenario observes the public result: both
creditAssessment and complianceCheck execute, underwriting consumes their
outputs, and the approval letter executes while the rejection notice is
SKIPPED. The parent never needs to know which child node produced the score.
Refactor without leaking the workshop
Rename riskScoring to calculateRisk, or insert a fraud check before it. If
the terminal output still satisfies {score, grade, reasons}, the parent graph
does not change. If the parent references an internal child node, reuse has
failed: a private refactor becomes a cross-graph migration.
Keep dynamic subgraphs as an advanced deployment technique. They change how a definition is selected at runtime; they do not change this contract rule. Learn the static parent/child output boundary first, then add dynamic selection only when the business truly chooses among definitions.
Break It Apart
The subgraph("name") syntax
node <id> : subgraph("<registered-name>") {
depends_on = [...]
input { ... }
timeout = ...
scope = isolated | parent // optional; defaults to isolated
}
The parser recognises the subgraph( token sequence and rewrites the operator
reference to an internal prefix (subgraph:<name>). The
StandardNodeCompiler
then looks up the name in the registered subgraph map and wires a
SubGraphOperator.
Registering subgraphs (Java side)
Before compiling the parent DSL, you build each child graph and register it:
// Build sub-graphs via Java API
Graph creditGraph = buildCreditAssessmentSubGraph(); // Graph.builder("credit-assessment")...
Graph complianceGraph = buildComplianceCheckSubGraph(); // Graph.builder("compliance-check")...
// Register before compile()
var compiler = new DslCompiler(registry);
compiler.registerSubGraph("credit-assessment", creditGraph);
compiler.registerSubGraph("compliance-check", complianceGraph);
Graph mainGraph = compiler.compile(ast);
(From LoanApprovalSubGraphDslExample.java, lines 274–284.)
Each child graph is built with the standard Graph.builder() API — operators,
dependencies, inputs, retry, timeout — exactly like a top-level graph. The only
difference is that it reads its initial data from ctx.* rather than a parent
node's output.
How output aggregation works
SubGraphOperator.execute()
runs the child graph and then:
Map<String, Object> output = new LinkedHashMap<>();
for (String terminalId : subGraph.terminalNodes()) {
Object terminalOutput = result.results().getRaw(terminalId);
if (terminalOutput != null) {
output.put(terminalId, terminalOutput);
}
}
return output;
The parent references each terminal by name:
creditAssessment.output.riskScoring navigates to the riskScoring key in
that map.
Scope: isolated vs parent
| Scope | Default for | Behaviour |
|---|---|---|
isolated | subgraph("name") | Child receives only explicit input {} bindings. No parent GraphContext leakage. |
parent | loop, foreach | Child inherits the parent GraphContext and can reference parent node outputs via __parentOutput_* keys injected at runtime. |
You can override the default:
node nested : subgraph("childGraph") {
scope = parent
input { key = parentNode.output.value }
}
With scope = parent, the SubGraphOperator merges the parent's
GraphContext into the child context before overlaying explicit inputs. This
is convenient for prototyping but weakens encapsulation — the child now
implicitly depends on the parent's context shape.
(See DSL Specification and StandardNodeCompiler.java for the scope rules and compilation path.)
Advanced: dynamic subgraphs
For cases where the child graph is not known at compile time, BLOGE provides
the dynamicSubGraph
operator. An upstream node generates DSL text; dynamicSubGraph sandbox-
validates, compiles, and executes it at runtime:
node plan : generateDynamicDsl {
input { task = ctx.task, history = ctx.history }
timeout = 3s
}
node executePlan : dynamicSubGraph {
depends_on = [plan]
input {
dslSource = plan.output.dslSource
context = { task: ctx.task, history: ctx.history, planKind: plan.output.planKind }
}
timeout = 15s
retry = { attempts: 2, backoff: 100ms, strategy: exponential }
}
(From dynamic-agent.bloge.)
dynamicSubGraph is a capability-layer operator registered via
CommonOperators.builder().dynamic().build(). It validates the generated DSL
with DslSandbox, enforces complexity limits via GraphComplexityValidator,
and runs the child through GraphEngine.executeNestedGraph().
Use subgraph("name") for compile-time composition. Use dynamicSubGraph
only when the graph structure must be decided at runtime (e.g. LLM-generated
plans, plugin architectures).
Common Trap
❌ Treating a subgraph node's output as a flat value
// WRONG — creditAssessment.output is a Map keyed by terminal node IDs
node decision : UnderwritingOp {
input {
riskGrade = creditAssessment.output.riskGrade // ← WRONG path
}
}
The subgraph node's output is a Map<String, Object> where each key is a
terminal node ID from the child graph. If the child's terminal node is
riskScoring, the correct path is:
// CORRECT — navigate through the terminal node ID
node decision : UnderwritingOp {
input {
credit = creditAssessment.output.riskScoring // ← terminal node ID
}
}
If the child graph has multiple terminal nodes, each one appears as a separate key. Check the child graph's structure to know which terminal IDs to reference.
❌ Using scope = parent as the default
It is tempting to add scope = parent everywhere to avoid threading every
value through input {}. Resist. Isolated scope forces you to declare the
contract — which inputs the child actually needs. This makes the subgraph
reusable across different parent graphs. Reserve scope = parent for
genuinely shared infrastructure context (e.g. tracing IDs, tenant keys).
Guided Rewrite
Look at
international-shipment.bloge.
It uses two parallel subgraphs — customsClearance and routeOptimization —
followed by a fan-in at bookingConfirmation.
Questions to work through:
-
How does the parent pass data to
customsClearance? (Through explicitinput {}bindings. The child readsctx.shipmentId,ctx.commodityType, etc., because those are injected from the parent's input block into the child'sGraphContext.) -
What is
routeOptimization.output.optimalRouteSelection? (It is the output of the terminal node namedoptimalRouteSelectioninside the route-optimization subgraph. TheSubGraphOperatoraggregated it.) -
What happens if you remove the
timeout = 30sfromcustomsClearance? (The subgraph node has no time limit. If the child graph hangs — say, an HS-code classification service is down — the parent graph stalls indefinitely at that node. Always timeout subgraph nodes.) -
Could you reuse the
customsClearancesubgraph in a different parent graph? (Yes — as long as you register the same child graph under"customs-clearance"and provide the expected inputs:shipmentId,commodityType,originCountry,destinationCountry,weightKg.)
Now try modifying the graph:
- Extract
trackingSetupandsendNotificationinto a new subgraph called"post-booking". Register it. Replace the two nodes with a singlenode postBooking : subgraph("post-booking") {}node. - What inputs must you pass? What terminal node ID will the parent reference?
Brain Check
-
What syntax references a pre-registered subgraph in DSL? (
node <id> : subgraph("<name>") { ... }.) -
How do you register a subgraph before compilation? (
compiler.registerSubGraph("name", graph)on theDslCompilerinstance.) -
What is the default scope for a
subgraph("name")node? (isolated— the child sees only explicitinput {}bindings.) -
How does a parent node read a subgraph's result? (Via
subgraphNode.output.<terminalNodeId>— theSubGraphOperatorcollects each terminal node's output into a map keyed by node ID.) -
When would you use
dynamicSubGraphinstead ofsubgraph("name")? (When the child graph's structure is determined at runtime — e.g. an LLM-generated plan or plugin-provided workflow.subgraph("name")is for compile-time composition.) -
Design question: Your compliance-check subgraph is used by three parent graphs. The loan-approval parent needs extra AML detail that the other two don't. Should you parameterise the subgraph, create a variant, or push the detail into the parent? (Prefer parameterisation via subgraph input. If the extra detail fundamentally changes the subgraph's pipeline, create a variant. Avoid pushing domain logic into the parent — that defeats the purpose of extraction.)
Lab
-
Open
loan-approval-subgraph.blogeandLoanApprovalSubGraphDslExample.java.- Trace the data flow: what does
creditAssessment.output.riskScoringresolve to at runtime? (A map with keysriskGrade,compositeScore,summary— the output of theriskScoringnode in the credit-assessment child graph.)
- Trace the data flow: what does
-
Open
smart-ticket-handling.bloge.- The graph has two subgraphs:
sentimentAnalysisandescalationWorkflow. Only one of them always runs. Which one, and why? (OnlysentimentAnalysisalways runs.escalationWorkflowis behind abranch on determinePriority.output.priority— it executes only when priority is"high".)
- The graph has two subgraphs:
-
Design a new graph for an e-commerce returns pipeline:
- Create a
"fraud-screening"subgraph with three nodes:orderLookup → returnHistoryCheck → fraudScore. - Create a
"refund-processing"subgraph with two nodes:calculateRefund → initiateRefund. - Write a parent graph that runs fraud screening first, branches on the fraud score, and routes clean returns to refund processing.
- What is the minimal
input {}contract each subgraph needs?
- Create a
-
Bonus: Open
dynamic-agent.bloge.- What happens if the
plannode generates invalid DSL? (TheDslSandbox.parseAndValidate()call insideDynamicSubGraphOperatorthrows. IfexecutePlanhas retry configured, the engine retries. If all attempts fail and there is no fallback, the graph fails.)
- What happens if the
Experiment acceptance card
- Expected and observed: The parent consumes terminal outputs only and does not depend on subgraph internals.
- Failure and recovery: Bind to a missing internal output; restore the terminal node ID and rerun.
- Proof boundary: Proves the subgraph output contract and isolation, not dynamic-subgraph safety.
- Exercise contract: Loan subgraph; change one output binding; deliver visible keys and failure reason; stop when only terminal keys are referenced.
Recap
subgraph("name")lets you embed a named, pre-built graph inside a parent graph as a single node. The child is registered withDslCompiler.registerSubGraph()before compilation.- Input flows through
input {}bindings, which become the child'sGraphContext. Output flows back via terminal nodes — the parent reads them assubgraphNode.output.<terminalNodeId>. - Subgraphs default to
isolatedscope: no parent context leakage. Usescope = parentsparingly — it weakens encapsulation. - Subgraphs are regular nodes in the parent DAG. They support
depends_on,timeout,retry, andfallbacklike any other node. Multiple subgraph nodes can run in parallel when their dependencies allow. - For runtime-generated graphs, use the
dynamicSubGraphoperator, which sandbox-validates and compiles DSL text at execution time. - The child graph runs via
GraphEngine.executeNestedGraph()— inheriting listeners, durability services, and execution-ID chaining from the parent.
Next Step
In Chapter 11 — Batch and Iteration you will learn
how foreach and loop let you repeat work over collections and polling
cycles — both of which compile their body as a nested subgraph under the hood.
Reference Links
- DSL Specification §5.9 — Scope Mode Compilation —
scope, default matrix, and compilation behaviour - Core Architecture — engine internals and nested execution
- Operator Design Specification — operator layer taxonomy (
SubGraphOperatoris a capability-layer operator) loan-approval-subgraph.bloge— primary exampleLoanApprovalSubGraphDslExample.java— Java wiring for subgraph registrationLoanApprovalSubGraphExample.java— Pure Java API version with typed recordsinternational-shipment.bloge— parallel dual-subgraph examplesmart-ticket-handling.bloge— subgraph + branch compositionorder-full-pipeline.bloge— payment + inventory subgraphsdynamic-agent.bloge— dynamic subgraph exampleSubGraphOperator.java— engine-level nested executionDynamicSubGraphOperator.java— sandboxed runtime DSL compilationDslCompiler.java—registerSubGraph()and import resolutionStandardNodeCompiler.java— subgraph node compilation logic
Coding Agent: Open the versioned task guide.