- Published on
The Graph Blueprint Pattern: How to Express Your LangGraph Topology as Data
- Authors

- Name
- Dan Orlando
- https://x.com/DanOrlando15
Pattern #1 in the HUGO Catalog (short for Heterogeneous Unified Graph Orchestrator). See the introduction for the catalog framing and why we lead with this one.
The function that materializes every LangGraph in our system has zero application imports. It knows about NodeSpec, EdgeSpec, ConditionalEdgeSpec, GraphBlueprint, and LangGraph's StateGraph. Nothing else. Every product supplies a different blueprint and runs through the same composer unchanged.
That's the whole pattern. The rest of this article explains why that one architectural fact makes every other pattern in the catalog possible, and shows the code we wrote to get there.
Although the HUGO platform was built on top of LangGraph, these patterns are in no way specific to LangGraph. They can be applied with virtually any ADK.
The graph builder used to know the whole application
Our main graph.py file used to import twelve application modules by name. To wire the conversation graph, it had top-of-file lines like these:
from app.graphs.normalization.graph import create_normalization_graph
from app.graphs.historical_interpreter import create_historical_interpreter_graph
from app.graphs.intent_classification.node import create_classify_intent_node
from app.graphs.orchestrator.graph import create_orchestrator_subgraph
from app.graphs.orchestrator.nodes import create_guardrails_node
# ... five more
Then, in the body of _build_graph, it called them imperatively:
graph.add_node("guardrails", guardrails_node)
graph.add_node("historical_interpreter", historical_interpreter_projection_node)
graph.add_node("normalize", normalization_subgraph)
graph.add_node("classify", classification_node)
graph.add_node("intent_router", intent_router_node)
# ... eight more add_node + add_edge lines
graph.add_edge("__start__", "guardrails")
graph.add_edge("guardrails", "historical_interpreter")
# ... eighteen more edges
Two problems compounded as the graph grew:
The graph builder file knew the entire application. Every subgraph's existence, its import path, its construction-time arguments. The file was the application's wiring diagram, embodied in code. A second product on the roadmap that needed a different topology would have to rewrite this end to end. With the number of products we had in the pipeline, that prospect was unworkable.
The graph topology was procedurally hidden. You couldn't look at the file and see the graph. You had to mentally execute a few dozen imperative add_node and add_edge calls in order to reconstruct the shape. Reviewers reading a graph-topology change had to trace through the calls one by one. New team members had to learn to read the file like a recipe.
The deeper problem under both: graph topology is data, but it was written as code. The cost of that mismatch grew with every subgraph we added. By the time the platform extraction started, that cost had become the single largest barrier between the medical AI engine and the next product on the list.
The pattern in this article is what we built when we asked: what would graph.py look like if the topology was a value the application passed in, instead of a procedure the platform executed?
Graphs are data
Before the code, the conceptual move.
A LangGraph StateGraph is, mathematically, just three things:
- A state class: the typed dictionary the graph carries through its nodes.
- A set of nodes, each one a name plus a callable (or compiled subgraph) that runs at that name.
- A set of edges, each one a
(source_name, target_name)pair, plus the branching kind: a source that fans into one of several targets chosen at runtime.
That's the whole abstraction. Everything else StateGraph offers — sentinels, projection, the compile step, the checkpointer hookup — is bookkeeping around those facts. The reason imperative wiring feels heavy is that it forces you to express static facts about a graph using active code. That impedance mismatch is what bloats files and hides intent.
If graphs are data, then a graph definition should be a record. A record you can read top-to-bottom and see the whole topology at a glance. A record an application owns, a platform consumes, and that neither side has to import from the other. The application writes the record. The platform reads it. The shape of the record is the contract.
This is the smallest possible shape that lets graph topology travel as a value. Everything in this article, and every other pattern in the catalog, sits on top of this idea.
The pattern
Name: Graph Blueprint.
Tagline: Express your LangGraph topology as a declarative data record instead of an imperative wiring procedure.
Intent: Decouple the what of a graph (which nodes exist, how they connect, which state class they share) from the how of running it (the materialization into a StateGraph, the checkpointer attachment, the compile step). Make the topology a value the application supplies and the materialization a function the platform owns.
Structure
Five small dataclasses are the entire abstraction surface.
@dataclass
class BuildContext:
"""Runtime resources available to node factories at graph-build time."""
runtime: LlmRuntime
prompts: PromptRegistry
state_cls: type
llm: BaseChatModel | None = None
@dataclass
class NodeSpec:
"""One node in a GraphBlueprint."""
name: str
factory: Callable[[BuildContext], Any]
projection: ProjectionSpec | None = None
@dataclass
class EdgeSpec:
"""A directed edge between two nodes."""
source: str
target: str
@dataclass
class ConditionalEdgeSpec:
"""A branching edge: one source, many possible targets."""
source: str
path: Callable[..., str] # (state) or (state, config) -> routing key
path_map: dict[str, str] # routing key -> target node
@dataclass
class GraphBlueprint:
"""Declarative description of a graph topology."""
state_cls: type
nodes: list[NodeSpec] = field(default_factory=list)
edges: list[EdgeSpec] = field(default_factory=list)
conditional_edges: list[ConditionalEdgeSpec] = field(default_factory=list)
Five types, each one doing exactly one job.
BuildContext is the runtime-resources bag the platform passes to every node factory. The factory uses it to construct its node: the LLM runtime for tool nodes that need one, the prompt registry for nodes that build system prompts, the state class for nodes that need to introspect it. The application doesn't construct the context; the platform does. This is the platform's dependency-injection seam. It lets an application-owned node receive platform-owned runtime resources without either side importing the other, which becomes the load-bearing detail once a second product shows up later in this article.
NodeSpec carries a name (how the graph refers to this node), a factory (a function that builds the node callable given a context), and an optional projection. The factory is the key. It lets the application say: I need a node here, but its construction depends on runtime resources the platform owns. Lazy, context-aware, and import-free at the application level. The application's node-construction code stays out of the blueprint constant itself.
EdgeSpec is trivially small on purpose. Two names. Most graph topology is just naming who flows into whom.
ConditionalEdgeSpec is the branching counterpart to EdgeSpec. Where a plain edge always flows from one node to one other, a conditional edge flows from one source into one of several targets, chosen at runtime. It carries a path function that inspects state and returns a routing key, and a path_map that translates each key into a target node name. The path_map is the important piece. It keeps the routing keys (semantic labels like "clinical") decoupled from the node names the graph wires together, so renaming a node never reaches into your routing logic. That decoupling is the same idea the Tool Contract pattern leans on hard: a stable contract surface that doesn't move when internal names do. In HUGO, the spec maps one-to-one onto LangGraph's add_conditional_edges(source, path, path_map), so it's a thin declarative skin over an already proven API. The path type is permissive (Callable[..., str]) because LangGraph inspects the callable's signature and injects config only when it accepts one.
ConditionalEdgeSpec was the last thing to become data. Nodes-as-data and edges-as-data are the easy two-thirds; routing was the holdout. Every graph builder we'd ever written had at least one add_conditional_edges call buried in its body, because branching reads as control flow rather than topology, and control flow is the thing you reach for code to express. Making the branch a record changes that. Once routing is a (source, path, path_map) triple sitting in a list, nothing imperative is left in the graph definition. The path function is still code, but it's application code the platform merely calls. The platform never decides where the graph goes; it forwards the decision.
GraphBlueprint is the top-level record. The application produces one. That's the whole "topology as data" surface: three lists and a class reference.
The composer
The function that turns a blueprint into a runnable graph splits into two pieces, and the split is doing more work than it looks:
def apply_blueprint(graph: StateGraph, blueprint: GraphBlueprint, ctx: BuildContext) -> None:
for spec in blueprint.nodes:
raw = spec.factory(ctx)
graph.add_node(spec.name, raw) # projection wrapping elided here
for edge in blueprint.edges:
graph.add_edge(_resolve_endpoint(edge.source), _resolve_endpoint(edge.target))
for cond in blueprint.conditional_edges:
graph.add_conditional_edges(
_resolve_endpoint(cond.source),
cond.path,
{k: _resolve_endpoint(v) for k, v in cond.path_map.items()},
)
def compose_graph(blueprint: GraphBlueprint, ctx: BuildContext) -> StateGraph:
state_cls = ctx.state_cls if ctx.state_cls is not None else blueprint.state_cls
graph = StateGraph(state_cls)
apply_blueprint(graph, blueprint, ctx)
return graph
compose_graph builds a fresh StateGraph and hands it to apply_blueprint. But apply_blueprint takes a graph it didn't create. That means you can call it on a graph the platform already populated, splicing an application fragment onto a framework-provided base. That capability gets its own section below.
Note the path_map values (target node names) are resolved through _resolve_endpoint, so a route to "__end__" reaches END; the keys are semantic routing labels and are left untouched. The conditional-edge loop is the entire cost of supporting branching: one pass that hands each ConditionalEdgeSpec straight to LangGraph's add_conditional_edges. The composer doesn't interpret the routing. It forwards the source, the path function, and the path map.
When a NodeSpec carries a projection, apply_blueprint wraps the subgraph's invocation in an async closure that maps the subgraph's output back into a delta on the parent state. That's the ProjectionSpec mechanism, for subgraphs that operate on a genuinely different state shape than their parent. It's a useful sub-pattern with one sharp edge, so it gets its own treatment under "When not to use it."
The key observation: this function has zero application imports. It knows about NodeSpec, EdgeSpec, ConditionalEdgeSpec, GraphBlueprint, BuildContext, and LangGraph's StateGraph. Nothing else. A second application that supplies a different blueprint runs through the same composer unchanged. That's the property the rest of the catalog depends on.
Splicing a fragment onto a platform-owned base
There are two ways an application can use this. The first is the one we've been describing: hand the platform a whole blueprint and let compose_graph materialize it. The second is subtler, and it matters more for multi-product use. Hand the platform a fragment and let it splice that fragment onto a base graph the platform owns.
Our orchestrator subgraph is platform-owned. Its core retrieval path — plan, run tools, execute, consolidate, generate, respond — is the same shape for every product, and the platform wires it imperatively because there's no reason to make a fixed internal path into data. But applications occasionally need to inject a branch into that path: a parallel node off the "about to generate" step that fans back into generate, say. The naive way to allow that is to add a parameter to the factory for every possible injection point, which is the per-app special-casing the blueprint was supposed to kill. Instead, the factory takes one optional parameter:
def create_orchestrator_subgraph(
runtime=None, prompts=None, state_cls=OrchestratorState,
policy=None, extension: GraphBlueprint | None = None,
):
graph = StateGraph(state_cls)
# ... platform wires the fixed core retrieval path imperatively ...
graph.add_edge("retrieval_consolidator", "generate")
if extension is not None: # app fragment, as data
ext_ctx = BuildContext(runtime=runtime, prompts=prompts, state_cls=state_cls)
apply_blueprint(graph, extension, ext_ctx) # same composer, partial graph
graph.add_edge("generate", "post_generation")
graph.add_edge("response", END)
return graph.compile()
This is open/closed expressed in one parameter. The platform's base topology is closed — you can't edit it from the application — but it's open to extension through a fragment supplied as data (the optional extension blueprint that gets passed into create_orchestrator_subgraph). The application describes its branch as nodes + edges + conditional_edges, exactly the way it describes a whole graph. The same apply_blueprint that builds a graph from scratch also overlays a fragment onto a partial one (or splices it in), because adding nodes and edges to a StateGraph is the same operation whether the graph is empty or half-built. The platform decides that extension is allowed and where it can attach, but the application decides what the extension contains.
A regression guard keeps this safe: a test that compiles the orchestrator with no extension and asserts the resulting node set is exactly the core set. As long as that test is green, an application can splice in whatever fragment it likes, with proof that the no-extension topology (the one every product shares) hasn't drifted underneath it.
A real example
Here's a scaled-down example blueprint that is loosely based on the one the medical AI engine ships:
MEDSCAPE_BLUEPRINT = GraphBlueprint(
state_cls=MedscapeOrchestratorState,
nodes=[
NodeSpec("guardrails", _guardrails_factory),
NodeSpec(
"contextualize_history",
_history_contextualization_factory,
projection=ProjectionSpec(output_fn=_history_contextualization_projection),
),
NodeSpec("normalize_query", _normalization_factory),
NodeSpec("classify", _classification_factory),
NodeSpec("intent_router", _intent_router_factory),
NodeSpec("orchestrator", _orchestrator_factory),
],
edges=[
EdgeSpec("__start__", "guardrails"),
EdgeSpec("guardrails", "contextualize_history"),
EdgeSpec("contextualize_history", "normalize_query"),
EdgeSpec("classify", "intent_router"),
EdgeSpec("intent_router", "orchestrator"),
EdgeSpec("orchestrator", "__end__"),
],
conditional_edges=[
ConditionalEdgeSpec(
source="normalize_query",
path=_route_by_intent, # (state) -> routing key
path_map={
"clinical": "classify", # research path: classify, route, orchestrate
"chitchat": "__end__", # nothing to research: skip straight to the end
},
),
],
)
You can read this top to bottom and see the conversation graph. The nodes are listed, the straight-line edges are listed, the branching point is listed, and the state class is named. Every factory the blueprint references lives in the same file, scoped to the application. The factories themselves do the local imports of orchestrator subgraphs, intent rules, and policies, but they do it inside the factory body, so the blueprint constant stays clean and the platform never sees those imports. The _route_by_intent function the conditional edge points at is the one piece that isn't a factory. It's a plain (state) -> key router that reads the classified intent off the state and returns one of the keys in the path_map. It lives in the same file, beside the factories, because the application owns its routing logic and the platform just calls it.
Compare that to twenty lines of graph.add_node(...) and graph.add_edge(...) calls scattered through a function body. The information content is identical. The legibility isn't.
A second blueprint: the proof of reuse
One blueprint demonstrates use. It can't demonstrate reuse, because a single example is consistent with a pattern that only ever fits the product it was extracted from. The convincing evidence is a second, visibly different blueprint that a different team wrote for a different product and that runs through the same unchanged composer.
CHAT_BLUEPRINT = GraphBlueprint(
state_cls=ChatState, # different state class
nodes=[
NodeSpec("guardrails", _guardrails_factory),
NodeSpec("intent_classification", _intent_classification_factory), # app-owned
NodeSpec("orchestrator", _orchestrator_factory), # platform-owned
],
edges=[
EdgeSpec("__start__", "guardrails"),
EdgeSpec("guardrails", "intent_classification"),
],
conditional_edges=[
ConditionalEdgeSpec(
source="intent_classification",
path=_intent_router,
path_map={"continue": "orchestrator", "escalate": "__end__"},
),
],
)
It diverges on every axis the pattern claims to support:
- Different
state_cls. The composer doesn't care; it builds theStateGraphfrom whatever class the blueprint names. - Different node set and a different routing concern. Their
ConditionalEdgeSpecexpresses an escalation decision,{"continue": "orchestrator", "escalate": "__end__"}, with nothing in common with the medical engine's decomposition fan-out. Same spec type, entirely different meaning. It's branching topology shipped as a record, on a product the platform's authors never touched. - Different policy, same orchestrator. Their
_orchestrator_factorypasses aChatPolicy()into the platform'screate_orchestrator_subgraph. The orchestrator is reused wholesale; only the policy is swapped. That swap is the Planning Policy pattern, and it rides in through the same factory seam everything else does.
The factory as a substitution point
Look more carefully at the second blueprint and you'll catch something the medical engine's blueprint doesn't show. Its intent_classification node is app-owned, and it sits at a named position alongside a platform-owned orchestrator node:
def _intent_classification_factory(ctx):
from nodes.intent_classification import create_intent_classification_node
return create_intent_classification_node(runtime=ctx.runtime, prompts=ctx.prompts)
One blueprint mixes app-owned nodes and platform-owned nodes in a single record, and the factory is what makes the mix invisible to the composer. Every example earlier in this article constructs a node. This one substitutes one. The factory decides, per position, whose node lives there, and the platform can't tell the difference, because both kinds are just Callable[[BuildContext], node].
Notice what the second's app-owned factory pulls out of the context: ctx.runtime and ctx.prompts. Their own node receives the platform's runtime resources through BuildContext without importing a single platform module. That's the whole point of the injection seam. A mixed-ownership graph doesn't require either side to reach across the boundary. The blueprint says what and where; BuildContext supplies the with-what; neither requires an import across the framework/app line.
Consequences
What this pattern enables:
- The platform's graph composer has zero application imports. A second application supplies a different blueprint with the same composer, and we've now watched that happen on a real second product.
- The application's topology is a value other code can introspect: diff it against the previous version, or render it to a diagram from the source of truth.
- Adding a new node means adding one
NodeSpecto a list. No file forks, no imperative wiring edits, no import additions at the top of a hot file. - The same composer that builds a whole graph also splices a fragment onto a platform-owned base graph (
apply_blueprint), which gives you an open/closed extension seam without a parameter-per-node factory. - Every other pattern in this catalog plugs in via
NodeSpec.factory, and the factory can substitute an app-owned node for a built-in, not only construct one. That's the meta-point of the next section.
What it costs:
- One layer of indirection. Reading the blueprint and reading the composer are two operations instead of one. The tradeoff is worth it the moment a second consumer exists, and well before that the moment your graph has more than five or six nodes.
- LangGraph's
add_conditional_edgesdoesn't fit the basicEdgeSpecshape. Conditional routing needs its own spec type, which is whyConditionalEdgeSpecis a separate list on the blueprint rather than a flag onEdgeSpec. That's a real, if small, cost: branching topology lives in a second list the reader has to consult, and the composer makes a second pass to wire it. We judged that cleaner than overloadingEdgeSpecwith optional routing fields that are unset on every straight-line edge. If your graph leans heavily on branching, expect the conditional-edge list to carry as much of the topology's meaning as the plain edge list does.
When not to use it
A small graph with three or four nodes that you don't expect to grow doesn't need a blueprint. The indirection won't pay back. The pattern becomes especially valuable starting around six or seven nodes, or any graph that needs to host more than one application's topology, which, if you're still reading, you probably do.
And don't reach for a ProjectionSpec just because a node happens to be a subgraph. This is the sharpest "when not to" we have, and we learned it by deleting one.
Use a projection only to remap between genuinely different state shapes. A projection's whole job is to translate: shape the parent state into the subgraph's input, then fold the subgraph's result back into a delta on the parent. When the shapes differ, that translation is real work and the projection earns its place. Our contextualize_history subgraph runs on a narrower state than its parent, so it still wraps that subgraph today.
But our orchestrator subgraph shares the parent's state shape, and we'd wrapped it in a projection too. That projection did two things, both wrong:
- Its
output_fnhand-copied roughly eighteen result fields back onto the parent state. That isn't translation; it's redundant field-copying that a native LangGraph reducer (add_messagesand friends) does for free. It was also a drift magnet. Every time we added a field the subgraph produced, someone had to remember to hand-patch it into the copy list. - The projection wrapped
await subgraph.ainvoke(...). That singleainvokecollapsed the entire subgraph into one opaque step from the streaming runner's point of view. We had inner nodes that wrote their results inside that opaque step, and those writes never reached the stream.
We replaced the orchestrator's projection with a plain pass-through node and ordinary edges. The native reducers handle the field merge; each inner node's write now streams the moment it happens. The rule that fell out of it:
A projection is a remap, not a wrapper. Use one only to bridge genuinely different state shapes. For a same-shape subgraph it's redundant field-copying, and if it wraps
ainvokeit silently destroys streaming granularity. If you want to watch a subgraph's inner steps stream, do not hide it behind a projection.
The tell is the output_fn. If it's copying fields straight across rather than translating between shapes, you don't have a projection. You have a manual reducer that also breaks your stream.
The surprising payoff: the blueprint made every other pattern easier
We expected the blueprint pattern to make graph.py smaller and more legible. It did. What we didn't expect: every other pattern in the catalog became easier to design once the blueprint was in place.
The reason is simple. Once topology is data, every other piece of the system that wants to plug into the graph plugs in through the blueprint, specifically through NodeSpec.factory. Tools register with a registry before composition; node factories pull the registry from the build context. Policies are passed into the orchestrator node factory at the moment of construction. LLM profiles are looked up by role inside each node factory. Skills are loaded into a registry the factories consult. None of those patterns need to touch the graph builder, and the graph builder doesn't know about any of them.
The blueprint is the integration boundary between application content and platform mechanics. Other patterns in the catalog respect that boundary by feeding into node factories (the application's hook into the blueprint) without mutating the blueprint itself or importing platform internals.
This is the meta-point of the whole catalog. Each pattern is a different kind of thing the application supplies to the platform:
- Blueprint → topology
- Tool contracts → executable interfaces
- Planning policies → orchestrator behavior
- LLM profiles → model selection
- Skills → conversational capabilities
Once you see those as five different seams, all of which the blueprint terminates into, the rest of the catalog reads less like a collection of tricks and more like one design move expressed in five places. That's why we lead the catalog with the blueprint. It isn't the most clever pattern. It's the one that makes the others coherent.
One honest boundary on that claim. Those five seams are all inbound: they describe what the graph is made of and how it runs. There's a sixth seam the blueprint deliberately does not terminate, the outbound one: how a node's output leaves the graph and reaches the client over the stream. It's a real boundary. The second application registers stream handlers and message-parse handlers for it's own fields, the same way node factories are supplied for those nodes owned by the application. But it's the mirror image of the blueprint's concern, not part of it. The blueprint governs the graph's interior; the outbound seam governs its exit. We cover that one in the Skill Output pattern (Pattern #5), where it belongs, as the outbound twin of everything in this article. Naming the boundary is the point. The blueprint terminates the inbound seams completely and the outbound one not at all, and that's a deliberate line rather than an omission.
The honest close: what topology-as-data still doesn't buy you
The honest scope: this pattern doesn't solve nodes that need to be constructed from per-request state (as opposed to per-process runtime resources), and it doesn't replace the need for good naming. A blueprint with thirty cryptically-named nodes is still hard to read.
It also doesn't solve graph evolution after bootstrap. The blueprint is finalized when the application starts. Once compose_graph runs, the set of nodes and edges is frozen until the process restarts. The graph's path through those nodes varies per request (conditional edges, runtime tool selection, skill activation, state mutation all work as you'd expect) but the node-and-edge set doesn't change. If your application needs the topology itself to vary per request (per-user custom graphs, user-defined workflows, self-modifying agents that grow new nodes) you need something closer to a graph-as-program-state model, which is a different beast entirely. For our use case, where every request runs the same topology and per-request variation lives in tool selection, skill activation, and state, the blueprint pattern is exactly right.
One last note for senior engineers about to apply this to their own codebase: don't underestimate how much the legibility win matters, separate from the reuse win. Even if you only ever ship one product on top of your graph, having the topology readable as a value pays back every time a new engineer tries to understand the system, or a reviewer reads a topology change, or you sit down with a diagram tool. The reuse case is the forcing function that gets the abstraction built. The legibility case is the daily compounding benefit. And when the reuse case finally arrives, when a developer on a second product writes one record, gets a working graph out of the same composer, and tells you the blueprint is powerful enough that he can just spit out whatever shape he needs, you find out the forcing function was worth it.
Next in the catalog: the Tool Contract Pattern. Once you have a blueprint, your orchestrator node needs to know what tools it can call without importing them by name. The Tool Contract pattern is what lets that conversation happen through a registry, and it's the next piece every application supplies to the platform via the blueprint.