Skip to main content

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

  1. Explain what a subgraph is in BLOGE and why it exists.
  2. Register a pre-built Graph with DslCompiler.registerSubGraph() and reference it in DSL using the node x : subgraph("name") {} syntax.
  3. Distinguish isolated scope (the default for subgraphs) from parent scope, and predict what each mode makes visible inside the child graph.
  4. Read subgraph output — understand that terminal-node outputs are aggregated into the parent node's result map.
  5. Recognise when to use the dynamicSubGraph operator for runtime-generated DSL instead of compile-time subgraph registration.

Prerequisites

Source Examples

FileWhat it shows
loan-approval-subgraph.blogeTwo parallel subgraphs (credit + compliance) feeding an underwriting decision with a branch
international-shipment.blogeParallel customs-clearance and route-optimisation subgraphs with fan-in
smart-ticket-handling.blogeSentiment-analysis subgraph feeding a priority branch into an escalation subgraph
order-full-pipeline.blogePayment and inventory subgraphs running in parallel
dynamic-agent.blogeRuntime DSL generation + dynamicSubGraph execution
LoanApprovalSubGraphDslExample.javaJava wiring: builds sub-graphs via Graph.builder(), registers them with DslCompiler.registerSubGraph()
SubGraphOperator.javaEngine-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

Diagram: 10-reuse-with-subgraphs figure 1

Diagram: 10-reuse-with-subgraphs figure 2

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:

  1. Input flows in through input {}. The bindings are injected as the child's GraphContext. The child graph reads them with ctx.* expressions.
  2. Output flows out through terminal nodes. The SubGraphOperator iterates subGraph.terminalNodes() and collects each terminal's output into a Map<String, Object>. The parent references them as subgraphNode.output.terminalNodeId.
  3. Scope defaults to isolated. The child does not see parent node outputs or inherit the parent GraphContext unless you explicitly set scope = 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:

  1. receiveApplication validates the incoming loan request.
  2. Two subgraphs run in parallelcreditAssessment and complianceCheck — each wrapping a four-node pipeline. Both depend on receiveApplication, so the engine schedules them concurrently.
  3. underwritingDecision fans in from both subgraphs. It reads creditAssessment.output.riskScoring — the terminal node's output from the credit subgraph — and complianceCheck.output.complianceDetermination.
  4. 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.

Diagram: parent and subgraph terminal-output boundary

Read the boundary from the outside in

ViewpointMay depend onShould not depend on
Child internalsIntermediate outputs such as income and debt ratioParent node names or parent scheduling
Child terminal contract{score, grade, reasons}Internal node layout
Parent underwritingDecisioncreditAssessment.outputcreditQuery.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

ScopeDefault forBehaviour
isolatedsubgraph("name")Child receives only explicit input {} bindings. No parent GraphContext leakage.
parentloop, foreachChild 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:

  1. How does the parent pass data to customsClearance? (Through explicit input {} bindings. The child reads ctx.shipmentId, ctx.commodityType, etc., because those are injected from the parent's input block into the child's GraphContext.)

  2. What is routeOptimization.output.optimalRouteSelection? (It is the output of the terminal node named optimalRouteSelection inside the route-optimization subgraph. The SubGraphOperator aggregated it.)

  3. What happens if you remove the timeout = 30s from customsClearance? (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.)

  4. Could you reuse the customsClearance subgraph 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 trackingSetup and sendNotification into a new subgraph called "post-booking". Register it. Replace the two nodes with a single node postBooking : subgraph("post-booking") {} node.
  • What inputs must you pass? What terminal node ID will the parent reference?

Brain Check

  1. What syntax references a pre-registered subgraph in DSL? (node <id> : subgraph("<name>") { ... }.)

  2. How do you register a subgraph before compilation? (compiler.registerSubGraph("name", graph) on the DslCompiler instance.)

  3. What is the default scope for a subgraph("name") node? (isolated — the child sees only explicit input {} bindings.)

  4. How does a parent node read a subgraph's result? (Via subgraphNode.output.<terminalNodeId> — the SubGraphOperator collects each terminal node's output into a map keyed by node ID.)

  5. When would you use dynamicSubGraph instead of subgraph("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.)

  6. 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

  1. Open loan-approval-subgraph.bloge and LoanApprovalSubGraphDslExample.java.

    • Trace the data flow: what does creditAssessment.output.riskScoring resolve to at runtime? (A map with keys riskGrade, compositeScore, summary — the output of the riskScoring node in the credit-assessment child graph.)
  2. Open smart-ticket-handling.bloge.

    • The graph has two subgraphs: sentimentAnalysis and escalationWorkflow. Only one of them always runs. Which one, and why? (Only sentimentAnalysis always runs. escalationWorkflow is behind a branch on determinePriority.output.priority — it executes only when priority is "high".)
  3. 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?
  4. Bonus: Open dynamic-agent.bloge.

    • What happens if the plan node generates invalid DSL? (The DslSandbox.parseAndValidate() call inside DynamicSubGraphOperator throws. If executePlan has retry configured, the engine retries. If all attempts fail and there is no fallback, the graph fails.)

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 with DslCompiler.registerSubGraph() before compilation.
  • Input flows through input {} bindings, which become the child's GraphContext. Output flows back via terminal nodes — the parent reads them as subgraphNode.output.<terminalNodeId>.
  • Subgraphs default to isolated scope: no parent context leakage. Use scope = parent sparingly — it weakens encapsulation.
  • Subgraphs are regular nodes in the parent DAG. They support depends_on, timeout, retry, and fallback like any other node. Multiple subgraph nodes can run in parallel when their dependencies allow.
  • For runtime-generated graphs, use the dynamicSubGraph operator, 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.


Coding Agent: Open the versioned task guide.