Graph Subgraphs Need Interface Contracts

Sep 11 2026 · 11 min · Sieon

If a parent graph can only use a subgraph by knowing its internal nodes, the system is still coupled. I treat every subgraph as a versioned interface: typed input, typed output, reducer ownership, persistence mode, interrupt payloads, and observable event shape all need explicit contracts before production rollout.

The decision rule: make subgraphs boring to replace

LangGraph defines a subgraph as a graph used as a node inside another graph, and its own documentation calls out multi-agent systems, node reuse, and independent team development as core use cases for the pattern LangGraph Subgraphs. That is exactly where production agent systems start to hurt. The code looks modular because every specialist agent has its own graph, but the runtime is not modular if the parent still depends on the child's private state keys, private node names, or private retry behavior.

My rule is simple: the parent should be able to replace a subgraph with a fake implementation in a contract test. If that fake only needs the declared input schema, output schema, persistence policy, interrupt payload shape, and stream event shape, the subgraph is an interface. If the fake has to mimic hidden nodes or undocumented state mutations, the subgraph is just a directory boundary.

This is a different failure mode from joins, replay, checkpoints, and interrupts. A join contract decides how parallel branches combine. A replay boundary decides which side effects can run twice. A checkpoint migration decides whether old state can resume under new code. A subgraph interface contract decides whether teams can evolve graph components independently while those other contracts still remain enforceable. LangGraph's Graph API describes workflows as State, Nodes, and Edges LangGraph Graph API. Subgraph design is where those three primitives become an organizational API.

The practical decision rule is: do not extract a subgraph because the canvas is crowded. Extract a subgraph when the boundary has a stable state contract and a clear owner. If you cannot name the owner of each shared state key, the reducer that merges it, and the checkpoint mode that preserves it, you do not have a subgraph interface yet.

Contract surface area

I write the subgraph contract as six fields. The fields are boring on purpose, because boring contracts survive retries, handoffs, and staffing changes.

Contract field What it freezes Why it matters
input_schema The parent fields the subgraph may read Prevents private parent state from leaking into child logic
output_schema The fields the subgraph may return Makes the parent integration testable without child internals
reducers Ownership and merge rule per output key Prevents accidental overwrite of shared state
persistence Per-invocation, per-thread, or stateless Defines memory, resume, and interrupt behavior
interrupts JSON payloads and resume commands Lets humans and clients resume without reading code
events Stream namespace and event payloads Lets observability track child work independently

LangGraph State is the shared data structure for the current application snapshot, and the docs say it is typically defined with a schema LangGraph Graph API. The same page says that graph state can use TypedDict, dataclasses, or Pydantic models, with Pydantic available when recursive validation is worth the performance cost LangGraph Graph API. For subgraph contracts, I prefer a narrow TypedDict or a Pydantic model only at the boundary. The inner graph can keep richer private state as long as the parent never sees it.

Reducers are part of the interface, not an implementation detail. LangGraph documents reducers as the per-key functions that decide how node updates are applied to State, and it says the default behavior is overriding the key when no reducer is specified LangGraph Graph API. That default is fine for a private field. It is dangerous for a shared field like messages, citations, tool_results, or risk_flags because two subgraphs may write the same key with different assumptions.

A clean contract names the owner of every shared key. If the research subgraph owns evidence, the planning subgraph should not append evidence directly. If both need to write risk_flags, the reducer should be explicit and deterministic. If a value exists only during execution, LangGraph provides UntrackedValue for fields that should not be checkpointed, such as database connections or temporary caches LangGraph Graph API. I do not let those values cross the subgraph boundary.

Two integration patterns, two failure modes

LangGraph gives two main subgraph integration patterns. When parent and child have different state schemas, the docs recommend calling the subgraph inside a node and writing a wrapper that maps parent state to subgraph input and maps child output back to parent state LangGraph Subgraphs. When the parent and child share state keys, the docs say you can compile the subgraph and pass it directly to add_node LangGraph Subgraphs.

Those two patterns fail differently.

The wrapper pattern fails when the mapping code becomes a silent compatibility layer. The parent passes customer_message, the child expects messages, and the wrapper quietly synthesizes missing fields. That looks helpful until the child starts relying on fields the parent never promised. I keep wrapper code intentionally small, usually only validation, projection, and normalization. If the wrapper starts performing business logic, the subgraph boundary is misplaced.

The shared-key pattern fails when teams assume that shared schema means shared ownership. It does not. A compiled subgraph attached as a node can read and write the same channels as the parent LangGraph Subgraphs. That is powerful when the child is truly part of the same state machine. It is risky when the child is owned by another team, another release cadence, or another persistence policy.

I use the wrapper pattern for externalized capabilities: retrieval, safety review, routing, enrichment, and human approval. I use the shared-key pattern for internal decomposition: a large planner graph split into smaller phases that still share one state model and one release train. If a subgraph needs to navigate back to a parent graph node, LangGraph's Command supports graph=Command.PARENT for parent graph navigation LangGraph Graph API. I treat that as an escalation path that deserves documentation, not a casual jump.

A runnable contract test

I like contract tests that run before the real graph is wired. This example is deliberately stdlib-only, so it can run in CI before installing LangGraph. The current LangGraph package version was 1.2.11 on PyPI at research time PyPI langgraph, and the optional validation library Pydantic was 2.13.5 on PyPI at research time PyPI pydantic. The test uses TypedDict, which Python documents as a way to describe dictionaries with string keys and typed values Python TypedDict documentation.

from __future__ import annotations

from dataclasses import dataclass
from typing import Any, Literal, NotRequired, TypedDict


class ResearchInput(TypedDict):
    query: str
    trace_id: str
    contract_version: Literal["research.v1"]


class ResearchOutput(TypedDict):
    answer: str
    citations: list[str]
    risk_flags: NotRequired[list[str]]
    contract_version: Literal["research.v1"]


@dataclass(frozen=True)
class SubgraphContract:
    name: str
    persistence: Literal["per_invocation", "per_thread", "stateless"]
    owned_output_keys: frozenset[str]
    append_only_keys: frozenset[str]


def fake_research_subgraph(payload: ResearchInput) -> ResearchOutput:
    assert payload["contract_version"] == "research.v1"
    assert payload["query"].strip()
    return {
        "answer": "Use a wrapper when parent and child schemas differ.",
        "citations": ["https://docs.langchain.com/oss/python/langgraph/use-subgraphs.md"],
        "contract_version": "research.v1",
    }


def validate_output(contract: SubgraphContract, output: dict[str, Any]) -> None:
    unknown = set(output) - contract.owned_output_keys - contract.append_only_keys
    if unknown:
        raise AssertionError(f"Unexpected output keys: {sorted(unknown)}")
    if output.get("contract_version") != "research.v1":
        raise AssertionError("Contract version mismatch")
    if not output.get("citations"):
        raise AssertionError("Research output must include citations")


def test_research_contract() -> None:
    contract = SubgraphContract(
        name="research",
        persistence="per_invocation",
        owned_output_keys=frozenset({"answer", "contract_version"}),
        append_only_keys=frozenset({"citations", "risk_flags"}),
    )
    output = fake_research_subgraph(
        {"query": "subgraph state", "trace_id": "tr_123", "contract_version": "research.v1"}
    )
    validate_output(contract, output)


if __name__ == "__main__":
    test_research_contract()

This test does not prove the child graph is correct. It proves the parent knows what it is allowed to depend on. That is the useful bar. Once this test exists, the real subgraph can be swapped in behind the fake, and the parent still has a stable interface to assert.

In a LangGraph implementation, I normally add one more CI check around the compiled graph: invoke the child with the smallest valid input, inspect the returned state, and fail if new public keys appear without a contract version bump. That check should run before persistence migrations, because persisted state is where hidden contract changes become production incidents.

Persistence and interrupts are part of the API

Subgraph persistence is a boundary decision. LangGraph documents compile(checkpointer=...) as the control for subgraph internal data between calls: None gives a fresh per-invocation call that inherits the parent checkpointer for interrupts and durable execution within one call, True accumulates state across calls on the same thread, and False disables checkpointing LangGraph Subgraphs. I put that choice in the interface contract because it changes what callers can safely assume.

Per-invocation is my default for specialist subgraphs. A billing specialist, retrieval specialist, or policy specialist should usually answer the current request and return a typed result. Per-thread persistence is appropriate when the subgraph is a long-lived collaborator that builds its own conversation state across turns. Stateless is appropriate only when the child has no need for interrupts or durable execution.

Persistence also decides whether an interrupt can be resumed. LangGraph's interrupt documentation says an interrupt requires a checkpointer, a thread ID, and a JSON-serializable payload LangGraph Interrupts. That is not just a runtime detail. It means the parent, client, and human approval surface all need to agree on the payload shape. A subgraph that emits {"kind": "approval", "draft_id": "..."} has a different interface from one that emits a free-form Markdown string.

Re-execution is the part teams miss. LangGraph documents that when execution resumes after an interrupt or retry, the affected node runs again from the start, because checkpoints are saved at super-step boundaries rather than inside a node function LangGraph Graph API. Side effects before the pause must therefore be idempotent. The Functional API docs make a related point: replay restores completed task and subgraph results from the checkpointer instead of recomputing them for a specific thread LangGraph Functional API overview.

That is why I include an effect_ledger in subgraph outputs when the child can touch external systems. The parent should see what idempotency key was used, what resource was touched, and whether the effect is safe to replay. If the child can send email, create tickets, call tools, or charge money, the subgraph interface is incomplete until it declares its replay policy.

Rollout checklist

A subgraph contract is only useful if it survives deployment. LangGraph's backward compatibility guide says the latest graph code is applied to both new threads and threads that resume from persisted checkpoints LangGraph Backward compatibility. That is convenient for bug fixes, but it turns every subgraph change into an API compatibility question.

Before changing a production subgraph, I run this checklist:

  1. Schema compatibility: new output fields are optional or versioned, and old fields remain readable until active threads drain.
  2. Node compatibility: renamed or removed internal nodes are checked against paused threads, because the compatibility guide warns that removing a node can break an interrupted thread that expects that node on resume LangGraph Backward compatibility.
  3. State compatibility: removed or tightened state fields are treated as checkpoint migrations, because the same guide warns that renamed keys or newly required fields can break existing checkpoints LangGraph Backward compatibility.
  4. Reducer compatibility: shared keys still have the same merge semantics, or the contract version changes.
  5. Persistence compatibility: the checkpointer mode remains stable, or the rollout includes explicit migration and resume testing.
  6. Interrupt compatibility: payloads remain JSON-serializable and clients can still submit the documented resume command.
  7. Observability compatibility: subgraph stream namespaces and event payloads are still emitted for dashboards and incident review.

Streaming belongs in the checklist because it is how operators see the boundary. LangGraph's streaming docs list stream modes such as updates, values, messages, custom, checkpoints, tasks, and debug, and stream parts include an ns namespace tuple populated for subgraph events LangGraph Streaming. If an extraction hides the child namespace, incident response gets worse even if the graph still returns the right final answer.

The rollout decision rule is: if the contract changed, version it; if persisted state changed, migrate it; if side effects changed, rehearse replay; if stream events changed, update observability before rollout. This is slower than refactoring a Python function. It is faster than debugging a suspended thread that resumed into a subgraph nobody owns.

FAQ

When should I call a subgraph inside a node?

Call a subgraph inside a node when parent and child schemas differ or when you need an explicit mapping layer, which is the pattern LangGraph documents for different state schemas LangGraph Subgraphs. I use this for boundaries between teams, specialists, or trust zones.

When should I add a compiled subgraph directly as a node?

Add a compiled subgraph directly when parent and child intentionally share state keys, which is the pattern LangGraph documents for shared channels LangGraph Subgraphs. I use this for internal decomposition inside one owned state machine.

Should every subgraph get per-thread persistence?

No. LangGraph documents per-invocation, per-thread, and stateless modes for subgraphs LangGraph Subgraphs. I start with per-invocation unless the child needs memory across calls. Per-thread persistence is a product decision, not a default convenience.

What is the smallest useful subgraph contract?

The smallest useful contract names input schema, output schema, reducer ownership, persistence mode, interrupt payloads, and stream events. Those fields map to the documented Graph API state and reducer model LangGraph Graph API, subgraph persistence modes LangGraph Subgraphs, interrupt requirements LangGraph Interrupts, and subgraph stream namespaces LangGraph Streaming.

References

  1. LangGraph Subgraphs
  2. LangGraph Graph API overview
  3. LangGraph Persistence
  4. LangGraph Interrupts
  5. LangGraph Streaming
  6. LangGraph Functional API overview
  7. LangGraph Backward compatibility
  8. PyPI langgraph
  9. PyPI pydantic
  10. Python TypedDict documentation