TechByteByByte

Subgraphs: Composing Large Workflows From Smaller Ones

Watch a real graph grow into something genuinely hard to read, then break it apart into modular, independently testable subgraphs — with a precise look at how parent and child state actually interact.

#LangGraph#Subgraphs#Composition#Testing

Recall the Customer Resolution Agent, growing steadily since Module 7. By now, if you’ve been extending it alongside every module, it genuinely has real logic for classification, billing, technical support, refunds, and escalation — all as nodes inside one, single, flat graph. This module addresses what happens next: that graph becoming genuinely difficult to read, test, and reason about, all at once.

This is a genuinely real, well-documented pattern, not just an aesthetic concern about tidy code. Asking one single, monolithic prompt — or one single, undifferentiated graph — to research, write, review, and format a complex task at once is a documented, recurring cause of real production problems: context window exhaustion, increased hallucination, and measurably degraded reasoning quality, precisely because the model or workflow is being asked to hold too many genuinely separate concerns in its attention at the same time. Decomposition into real, focused subgraphs isn’t merely a readability preference — it’s a direct, practical response to this exact, documented failure mode.

The real problem, stated plainly

flowchart TD
    START --> classify
    classify --> billing_check
    billing_check --> billing_lookup
    billing_lookup --> billing_action
    classify --> tech_diagnose
    tech_diagnose --> tech_fix
    tech_fix --> tech_verify
    classify --> refund_check
    refund_check --> refund_approve
    refund_approve --> refund_process
    billing_action --> resolution
    tech_verify --> resolution
    refund_process --> resolution
    resolution --> END

This is a genuinely realistic shape for a real customer support system — and it’s already hard to read as one flat diagram, let alone one flat block of Python. Three entirely separate concerns — billing, technical support, refunds — are tangled together in a single graph, meaning a change to refund logic risks accidentally touching billing code sitting right next to it, and testing “just the billing path” means running the entire graph.

The fix: a subgraph is just a compiled graph, used as a node

from typing import TypedDict
from langgraph.graph import StateGraph, START, END

class BillingState(TypedDict):
    query: str
    billing_result: str

def check_account(state: BillingState) -> dict:
    return {"billing_result": "Account in good standing."}

def apply_credit(state: BillingState) -> dict:
    return {"billing_result": state["billing_result"] + " Credit applied."}

billing_builder = StateGraph(BillingState)
billing_builder.add_node("check_account", check_account)
billing_builder.add_node("apply_credit", apply_credit)
billing_builder.add_edge(START, "check_account")
billing_builder.add_edge("check_account", "apply_credit")
billing_builder.add_edge("apply_credit", END)

billing_subgraph = billing_builder.compile()

Notice billing_subgraph is genuinely nothing new — it’s a real, complete, independently compiled StateGraph, exactly like every graph you’ve built throughout this course. The only thing that makes it a “subgraph” is what happens next.

Example 1: using a compiled subgraph as a single node

from typing import TypedDict
from langgraph.graph import StateGraph, START, END

class MainState(TypedDict):
    query: str
    billing_result: str

main_builder = StateGraph(MainState)
main_builder.add_node("billing", billing_subgraph)  # a compiled graph, used directly as a node
main_builder.add_edge(START, "billing")
main_builder.add_edge("billing", END)

main_graph = main_builder.compile()
result = main_graph.invoke({"query": "Why was I charged twice?", "billing_result": ""})
print(result["billing_result"])

billing_subgraph, already fully compiled, is passed directly to add_node("billing", billing_subgraph) — no special wrapping required. This works because MainState and BillingState genuinely share the same field names (query, billing_result) — LangGraph passes the relevant, overlapping state through automatically.

Example 2: when parent and child state genuinely differ

Real subgraphs often have a completely different, internal shape — recall Module 3’s own separate input_schema/output_schema pattern; the same real idea applies here.

from typing import TypedDict
from langgraph.graph import StateGraph, START, END

class RefundInternalState(TypedDict):
    order_id: str
    approved: bool
    amount_refunded: float

def check_eligibility(state: RefundInternalState) -> dict:
    return {"approved": True}

def process(state: RefundInternalState) -> dict:
    return {"amount_refunded": 49.99 if state["approved"] else 0.0}

refund_builder = StateGraph(RefundInternalState)
refund_builder.add_node("check_eligibility", check_eligibility)
refund_builder.add_node("process", process)
refund_builder.add_edge(START, "check_eligibility")
refund_builder.add_edge("check_eligibility", "process")
refund_builder.add_edge("process", END)
refund_subgraph = refund_builder.compile()

class MainState(TypedDict):
    query: str
    order_id: str
    resolution_summary: str

def run_refund_subgraph(state: MainState) -> dict:
    # translate: parent state -> subgraph's own internal shape
    subgraph_result = refund_subgraph.invoke({"order_id": state["order_id"], "approved": False, "amount_refunded": 0.0})
    # translate back: subgraph's result -> parent state
    return {"resolution_summary": f"Refunded ${subgraph_result['amount_refunded']}"}

main_builder = StateGraph(MainState)
main_builder.add_node("refund", run_refund_subgraph)
main_builder.add_edge(START, "refund")
main_builder.add_edge("refund", END)
main_graph = main_builder.compile()

result = main_graph.invoke({"query": "I want a refund", "order_id": "o_1", "resolution_summary": ""})
print(result["resolution_summary"])

Notice run_refund_subgraph is an ordinary node function — genuinely no different from any node you’ve written throughout this course — that happens to call refund_subgraph.invoke(...) internally, translating between the parent’s state shape and the subgraph’s own, different, internal shape explicitly, in both directions. This is the real, correct pattern whenever a subgraph’s internal state genuinely doesn’t align with its parent’s.

Example 3: the real refactor, applied to the Customer Resolution Agent

class ResolutionState(TypedDict):
    query: str
    category: str
    billing_result: str

def classify(state: ResolutionState) -> dict:
    return {"category": "billing" if "charge" in state["query"].lower() else "technical"}

def route(state: ResolutionState) -> str:
    return state["category"]

main_builder = StateGraph(ResolutionState)
main_builder.add_node("classify", classify)
main_builder.add_node("billing", billing_subgraph)
main_builder.add_conditional_edges("classify", route, {"billing": "billing", "technical": "billing"})
main_builder.add_edge(START, "classify")
main_builder.add_edge("billing", END)

resolution_graph = main_builder.compile()

Compare this directly against this module’s opening diagram — the same real capability, but now billing’s entire internal logic lives in its own, separately defined, separately testable billing_subgraph, rather than being tangled directly into the main graph’s own structure.

Why this matters directly for testing

# test the billing subgraph completely on its own, with no dependency on the rest of the system
def test_billing_subgraph_applies_credit():
    result = billing_subgraph.invoke({"query": "test", "billing_result": ""})
    assert "Credit applied" in result["billing_result"]

This is the real, concrete payoff of decomposition: billing_subgraph can be tested in complete isolation, without invoking classify, without touching technical or refund logic at all — exactly the same real testing discipline Module 8’s clean routing-versus-computation separation was building toward.

Common mistakes worth avoiding

Assuming every subgraph needs explicit state translation. Recall Example 1 — when field names genuinely align between parent and child, a compiled subgraph can be added directly as a node, with no wrapper function needed at all. Only reach for Example 2’s translation pattern when the shapes genuinely differ.

Over-decomposing a genuinely small, simple graph into unnecessary subgraphs. Recall this module’s own opening motivation — subgraphs solve a real problem of genuine complexity and tangled concerns. A three-node graph doesn’t need to be split into three separate subgraphs; that adds real indirection without a real, corresponding benefit.

Forgetting that a subgraph invoked via a wrapper node, as in Example 2, doesn’t automatically share the parent’s checkpointer configuration. A subgraph called this way runs as an ordinary function call within the parent node — if it genuinely needs its own independent checkpointing behavior, that needs to be configured deliberately, not assumed.

What you should take away from this module

  • A subgraph is genuinely nothing more than a normal, independently compiled StateGraph, used as a node in a larger, parent graph.
  • When parent and child state share field names, a compiled subgraph can be added directly as a node — state passes through automatically.
  • When they genuinely differ, a plain wrapper node function translates between the two shapes explicitly, in both directions.
  • The real, practical payoff is modularity: each subgraph can be built, understood, and tested in complete isolation from the rest of the system.

Where this goes next

The next module puts subgraphs to real, direct use: Multi-Agent Systems, where the “subgraphs” genuinely become separate, specialized agents — a supervisor, and the real specialists it coordinates — implementing the multi-agent patterns you already know conceptually from your earlier coursework.

Author
TechByteByByte Editorial Team
Reviewed by
TechByteByByte Admin
Published
Last reviewed