I implemented the same agent with Spring AI and LangGraph — the boundary didn't move
Part 6 findings of an experiment: building an LLM-powered support agent with deterministic boundaries. The companion repo contains the full code.
"Spring AI or LangChain?"
It's the first question everyone asks, and I answered it the expensive way: I built the same agent twice.
The frameworks turned out to be the least interesting decision in the design.
Here's what the comparison actually taught, which is not what I expected to write.
What each framework owns (and doesn't)
In both implementations, the framework does exactly one thing: turn a customer message into a Classification — a typed record naming the action and carrying the order id.
Everything after that point is identical: eligibility rules, risk-tier gate, approval queue. Same code, same tests, same behaviour.
// dev/tonal/support/ai/SpringAiIntentClassifier.java
public final class SpringAiIntentClassifier implements IntentClassifier {
private final ChatClient chatClient;
public SpringAiIntentClassifier(ChatClient.Builder chatClientBuilder) {
this.chatClient = chatClientBuilder
.defaultSystem(SYSTEM_PROMPT)
.build();
}
@Override
public Classification classify(String customerMessage) {
return chatClient.prompt()
.user(customerMessage)
.call()
.entity(Classification.class);
}
}
That's the entire integration surface — one class, one method. The LangGraph version differs only in mechanics:
| Concern | Spring AI | LangGraph |
|---|---|---|
| Tool definition |
@Tool on methods |
@tool decorator |
| Structured output | .entity(Classification.class) |
response_format=Pydantic |
| Orchestration loop | Advisor chain | ReAct graph |
| Provider swap | Starter dependency | Model class |
flowchart LR
subgraph S["Spring AI implementation"]
direction LR
S1["ChatClient prompt"] --> S2[".entity(Classification)"]
end
subgraph L["LangGraph implementation"]
direction LR
L1["ReAct agent"] --> L2["structured_response"]
end
S2 --> P["Classification record"]
L2 --> P
P --> G{"GatedActionService<br/>(unchanged)"}
classDef step fill:#eef2f6,stroke:#8fa3b8,color:#24313f
classDef shared fill:#ecf2ed,stroke:#93b39d,color:#3d5344
classDef decision fill:#f7f4ec,stroke:#b3a988,color:#24313f
class S1,S2,L1,L2,P step
class G decision
style S fill:#f7f9fb,stroke:#c5d1dc,color:#24313f
style L fill:#f7f9fb,stroke:#c5d1dc,color:#24313f
Different vocabulary, same job. Neither framework knows or cares that downstream sits a refund policy engine that would reject half its suggestions.
The test that proves it
The repo contains an end-to-end test whose classifier is neither framework — it's a keyword fallback (which doubles as provider-outage insurance). Swap any implementation behind the port; the test doesn't change:
@Test
void classifiedRefundRequestLandsInHumanApprovalQueue() {
Classification classification =
classifier.classify("I want a refund for ORD-101, it never arrived");
var result = gate.propose(
classification.action(),
"refund %s — %s".formatted(classification.orderId(), classification.reason()));
assertThat(result.outcome())
.isEqualTo(GatedActionService.Outcome.QUEUED_FOR_APPROVAL);
}
Spring AI, LangGraph, keyword matching — all three produce the same record, hit the same gate, land in the same queue. If your framework choice changes what your system does, that logic was in the wrong place to begin with.
Where the frameworks do differ
To be fair, they aren't interchangeable in every dimension. Spring AI keeps everything in one language and build; LangGraph gives you graph-shaped control flow that's nicer for complex multi-step reasoning — and costs you a second runtime. Spring AI's advisor model is thinner than LangGraph's node/edge composition; if the agent grows genuinely complex orchestration, that gap matters. Today's agent has exactly two steps, so it doesn't.
MCP deserves a mention here too: both ecosystems speak the Model Context Protocol, meaning tools defined once can be consumed from either stack. More evidence that the durable decisions live at the protocol level, not inside any framework.
The lesson, named
Choosing between agent frameworks is a real decision — but it's a week-two decision about developer ergonomics, not a design decision about what your system may do. The things that made this system trustworthy were all decided before either framework was picked: which judgments go to the model, which facts stay in code, who approves consequential actions. Those survive every framework migration you'll ever do.
Loan underwriting didn't change when scoring engines were swapped; clinical systems kept their prescription boundaries through three generations of decision support. Same rule every time.
The boundary is the architecture. The framework is a dependency.