One Agent, Two Frameworks, Zero Lessons About Frameworks

java dev.to

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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.

Source: dev.to

arrow_back Back to Tutorials