One chatbot, built twice, measured
Everything on this page is reproducible from github.com/picoflowio/ezgraph-demo. Both implementations live side by side, share the same domain backend, and pass the same live evaluation. If you think a number here is wrong, the control group is right there to check it against. Start with the QuoteGraph walkthrough if you have not opened the tree yet.
quote-graph — the EZGraph implementation
Five conversational stages as five node classes, plus a graph definition and a state annotation. 617 normalized lines.
quote-langgraph — the control group
The identical product on raw LangGraph, importing no EZGraph code: one 980-line graph class, a 177-line session store, a state annotation, and a types module. 1,283 normalized lines.
Same product: Sequoia Auto Insurance quoting. Five ordered stages — driver, vehicle,
insurance history, coverage, quote — with 8 tools, 3 isolated history channels, real
slot validation, gates that refuse to advance on invalid input, a backward transition
from quote to coverage, a deterministic rating engine, 30-minute idle expiry, and a
terminate_session path. Both share the same rating engine,
prompts, vehicle catalog and clock helpers — 287 normalized lines each, which is
how we know the delta is framework cost rather than a difference in scope.
The headline metric
Normalized executable lines: blank lines, comment-only lines, and import statements removed, so neither side is rewarded for terse imports or punished for documenting itself.
| Scope | EZGraph quote-graph | LangGraph quote-langgraph | Reduction |
|---|---|---|---|
| Framework-facing graph code | 617 | 1,283 | 51.9% less |
| Shared domain backend | 287 | 287 | 0% — identical |
| NestJS controller | 47 | 74 | 36.5% less |
| Graph code + controller | 664 | 1,357 | 51.1% less |
| Raw lines, nothing removed | 924 | 1,588 | 41.8% less |
File by file
| EZGraph | Lines | LangGraph | Lines |
|---|---|---|---|
quote-graph.ts | 49 | quote-langgraph.ts | 980 |
quote-graph.state.ts | 66 | quote-langgraph.state.ts | 83 |
nodes/driver.node.ts | 81 | quote-session-store.ts | 177 |
nodes/vehicle.node.ts | 110 | quote-types.ts | 43 |
nodes/history.node.ts | 79 | — | — |
nodes/coverage.node.ts | 71 | — | — |
nodes/quote.node.ts | 161 | — | — |
| Total | 617 | Total | 1,283 |
Where the 666 lines went
The first four rows are the current, additive source artifacts behind the headline. The remaining rows are independently recounted repetition counts: they explain what makes the source artifacts different, but are not added again. The comparison does not count framework source on either side. The main reduction is recurring application plumbing, not insurance-domain logic.
| Concern | LangGraph | EZGraph | Delta |
|---|---|---|---|
| Source artifacts — additive normalized lines | |||
| Main graph and all stage implementations | 980 | 551 | −429 |
| State definition and reducers | 83 | 66 | −17 |
| Session store — 3 backends + serialize/hydrate | 177 | 0 | −177 |
| Standalone domain type module | 43 | 0 types live with node state | −43 |
| Framework-facing graph code | 1,283 | 617 | −666 |
| Repetition counts — not additive line buckets | |||
Manual Schema.parse(call.args) blocks | 8 | 0 | −8 |
| Tool-name dispatch comparisons | 14 | 0 | −14 |
Explicit route: state writes | 16 | 0 | −16 |
| Termination handling sites in graph code | 7 | 0 built-in node | −7 |
| Reducer helpers / channels | 2 × 17 | 0 | framework-owned |
| Domain tool declarations | 8 | 8 | same product scope |
| Explicit response sites | route/branch updates | 5 go, 8 stay, 1 direct, 1 finish | visible contract |
The expensive work is concentrated
The direct graph's 980-line file owns stage prompts, tool declarations and dispatch, model calls, message handling, validation, persistence boundary, and routing. The recounted repetition rows show the maintenance cost hidden inside that one artifact. EZGraph splits its corresponding 551 lines between a small graph definition and five stage files, while the framework owns the repeated turn mechanics.
The domain logic itself is unchanged between the two implementations, and that is the claim in one sentence: this is not a DSL that compresses business rules, it is a framework that deletes plumbing.
What this count does not claim
It does not claim that a line count measures quality, performance, or every LangGraph application. The quote application's provider, prompts, domain rules, and rating backend remain intentionally equivalent.
The source counts are useful because they are reproducible and scoped: they include application code, exclude the framework itself, and keep matching domain backend code out of the headline.
Modularity, measured as edit sites
Line counts are a proxy. The honest test of modularity is how many places you touch
to add a sixth stage — say a DiscountsNode between coverage and quote.
This is the number that predicts your team's velocity in month six.
LangGraph — 14 edit sites
| # | Edit | Location |
|---|---|---|
| 1 | Add "discounts" to the QuoteStage union | quote-langgraph.ts:52 |
| 2 | Add a stageMessageKey entry | :92–98 |
| 3 | Declare the Zod schema const | :100–160 |
| 4 | Wrap it in a tool() const | :162–212 |
| 5 | Add a stageTools entry | :214–225 |
| 6 | Bind a model in the constructor | :267–273 |
| 7 | Write the discountsAgent method | :473–526 |
| 8 | Write the discountsTools method | :553–932 |
| 9 | addNode("discountsAgent", …) | :427–471 |
| 10 | addNode("discountsTools", …) | :427–471 |
| 11 | addConditionalEdges for the agent, plus one for the tools node | :427–471 |
| 12 | Add an agentRoutes entry | :957–964 |
| 13 | Add to QuoteLanggraphPhase | state.ts:12–18 |
| 14 | Add to QuoteLanggraphRoute, plus state channels for the new data | :20–26, :39–100 |
Eleven of these are one-line edits to shared module-level structures, and nothing
checks that they agree. A missed agentRoutes entry is a runtime
KeyError on the first customer who reaches that stage — in production,
not in CI.
EZGraph — 5 edit sites
| # | Edit | Location |
|---|---|---|
| 1 | Write nodes/discounts.node.ts — the whole stage: prompt, tools, handlers, validation, state saves, and responses | new file |
| 2 | Add a DiscountsNode?: NodeStateValue<…> key | quote-graph.state.ts |
| 3 | Add [DiscountsNode, "quote-intake"] to historySpaces | quote-graph.ts:37–44 |
| 4 | Add DiscountsNode to registerTurnNodes(...) | quote-graph.ts:64–71 |
| 5 | Change the upstream go(QuoteNode) to go(DiscountsNode) | coverage.node.ts |
One new file plus four one-line edits — and edits 3, 4 and 5 take the class, so
forgetting one is a compile() error or a type error rather than a
production KeyError. Conversational routing lives in the explicit
go(TargetNode) response returned by the upstream node.
Boilerplate patterns, counted
Exact ripgrep counts over the two trees. These are the repetitions that
grow every time you add a tool or a stage.
| Pattern | LangGraph | EZGraph |
|---|---|---|
Manual Schema.parse(call.args) in try/catch | 8 | 0 |
Tool-name dispatch checks — call.name === / !== | 14 | 0 |
terminate_session handling sites | 7 | 0 framework node |
Explicit route: writes in application code | 16 | 0 |
| Hand-written reducers | 2 helpers × 17 channels | 0 |
| Hand-written session store implementations | 3 | 0 4 ship with it |
| Tool handler declarations | 8 in if/else chains | 8 @Tool decorators |
| Quit checks in application code | 5 inside dispatch chains | 5 quitRequested guards |
terminate_session row is the contract argument in one line. In
LangGraph it is a schema, a tool() wrapper, an entry in all five
stageTools lists, and a handler branch in all five tool nodes — seven sites,
and a sixth stage makes it nine. In EZGraph every ConversationNode inherits
the handler, TerminateSessionNode supplies the definition once,
quit() routes through it, and compile() fails loudly if you
forget to register it. Seven sites become zero, and the failure mode moves from
"one stage silently can't be quit" to a build error.
Writing the LangGraph version honestly surfaced a real defect: the hand-rolled agent
loop services only one tool call per pass. Give it a genuine multi-tool batch and
it appends one ToolMessage and drops the rest — a conversation shape
providers reject. Nobody wrote that bug on purpose. It is what happens when the loop is
application code that has to be reimplemented per stage, and it is the strongest
argument on this page for moving the loop into a framework where it gets written once
and tested once.
How to reproduce every number
A benchmark you cannot re-run is an advertisement. Here is the exact normalizer.
function normalize(lines) {
let total = 0, inBlockComment = false, inImport = false;
for (const raw of lines) {
const line = raw.trim();
if (inBlockComment) { if (line.includes("*/")) inBlockComment = false; continue; }
if (inImport) { if (/from\s+["'].*["']/.test(line) || /;\s*$/.test(line)) inImport = false; continue; }
if (line === "") continue;
if (line.startsWith("//")) continue;
if (line.startsWith("/*")) { if (!line.includes("*/")) inBlockComment = true; continue; }
if (line.startsWith("*")) continue;
if (/^import\b/.test(line)) {
if (!/from\s+["'].*["']\s*;?\s*$/.test(line)) inImport = true;
continue;
}
total += 1;
}
return total;
}
Pattern counts are plain ripgrep over the two trees — for example, the
manual argument-parse count:
rg -c 'Schema\.parse\(call\.args\)' src/graphs/quote-langgraph/
Honesty notes
- Same authors, both sides. A LangGraph specialist would write the control group somewhat tighter. What they could not do is delete the session store (177), the request boundary (187), the tool dispatch (113), the agent loop (79) or the reducers (64) — 620 lines of pure plumbing that a tighter style trims by a few percent at best.
- The 177-line session store is a fair charge, not a strawman. A LangGraph app could use a checkpointer instead — but then it inherits the schema-migration and retention problems in pain point 01, which is precisely why the control group writes an application-level store.
- EZGraph's 617 lines exclude the framework itself. That is the point of a framework — but it does mean you are trading code you own for code you don't. Read the limits before deciding that trade is worth it.
- One data point. One five-stage guided conversation. Generalize from the per-concern table, not from 51.9%: if your app needs persistence, a request boundary, tool dispatch, an agent loop and state reducers, expect a similar result. If it needs none of them, don't adopt this.
Primary sources
The pain points on this site are not invented. Each is documented by LangGraph's own documentation, its v1 feedback thread, or a public production write-up.
LangGraph documentation
Credit where it is due. LangGraph is a genuinely good low-level orchestration
runtime, and EZGraph runs on it — every graph here compiles to a real
StateGraph and LangGraph executes it. Nothing on this page is a claim that
LangGraph is doing something wrong. It is a claim that a guided enterprise chatbot needs
an application layer above it, and that you should not have to write that layer five
times per company.