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