back
Spring AI Agentic Patterns - and When Not to Build an Agent
Aug 28, 2026·9 min read
Part 8 of 8·Spring AI in Production

Spring AI Agentic Patterns - and When Not to Build an Agent


“Agent” has become the least precise word in this field. It’s used for a chatbot with one tool and for a system that plans and executes for twenty minutes unsupervised.

The useful definition is narrow: an agent is a system where the model decides the control flow. In a normal application you write the steps. In an agentic one, the model chooses what happens next, based on what it has seen so far. That single difference is where all the power and all the operational difficulty comes from.

This is the last part of the series. It’s also the one where I’ll spend the closing section arguing against most of what precedes it.

Chaining, which is barely an agent

The simplest pattern and the most underrated. Break a task into steps and feed each output into the next.

Crucially, you write the sequence. The model decides nothing — which is exactly why it’s reliable.

Extraction extraction = chatClient.prompt()
        .user("Extract the key figures from:\n" + document)
        .call()
        .entity(Extraction.class);

Analysis analysis = chatClient.prompt()
        .user("Analyse these figures:\n" + extraction)
        .call()
        .entity(Analysis.class);

String summary = chatClient.prompt()
        .user("Write a two-paragraph summary of:\n" + analysis)
        .call()
        .content();

Three focused calls instead of one enormous one, and the output is genuinely better. Ask a model to “extract, analyse and summarise” in one go and it does all three adequately. Ask it to do one thing at a time and each one is good.

The operational advantages matter more:

The cost is latency — three sequential round trips. Reach for this pattern first. A large share of “we need an agent” problems are chaining problems, and chaining is testable in a way agents aren’t.

Routing

Classify the request, then send it to a specialist handler. The classification is structured output into an enum, so the result is type-safe and the switch is exhaustive:

Category category = chatClient.prompt()
        .user("Classify this request:\n" + request)
        .call()
        .entity(Category.class);

return switch (category) {
    case BILLING   -> billingClient.handle(request);
    case TECHNICAL -> technicalClient.handle(request);
    case COMPLAINT -> humanQueue.escalate(request);
};

Why route rather than build one client that does everything:

Keep the classifier cheap and fast — it runs on every request. A small model with a tight prompt is usually plenty, and it’s easy to validate because the output space is a handful of enum values.

Parallelization

When subtasks are independent, run them concurrently. Same latency as the slowest branch instead of the sum of all of them.

var futures = documents.stream()
        .map(doc -> CompletableFuture.supplyAsync(
                () -> summarize(doc), executor))
        .toList();

List<String> summaries = futures.stream().map(CompletableFuture::join).toList();

Ordinary Java concurrency — the model calls are just I/O. Two things to remember: providers rate limit, so bound your executor rather than fanning out unboundedly, and parallel calls cost exactly as much as sequential ones. You’re buying latency, not money.

Orchestrator–workers and evaluator–optimizer

These two are where the model genuinely takes control.

Orchestrator–workers: a planning call decomposes the task into subtasks, worker calls execute them, and a final call synthesises the results. Use it when the subtasks genuinely can’t be known in advance — a research task where what you find in step one determines what’s worth doing in step two.

Evaluator–optimizer: generate, critique, revise, repeat. One call produces a draft, another grades it against criteria, and if it falls short the feedback goes back into a revision. This works surprisingly well for things with clear quality criteria — code that must compile, text that must fit a style guide, output that must satisfy a schema.

Both need a hard limit on iterations. A self-critique loop with no ceiling is a way to spend an unbounded amount of money on a task that was never going to converge. Cap the iterations, cap the total tokens, cap the wall-clock time, and treat hitting any of those as a failure you log rather than a normal outcome.

Approval gates

Part five ended with a warning about write tools. This is the answer.

The agent proposes, a human disposes. Nothing irreversible happens without explicit approval — and the way to implement that is for the tool to not do the thing:

@Tool(description = "Request cancellation of an order. Creates a pending approval; does not cancel.")
public String requestCancellation(long orderId, ToolContext context) {
    approvals.create(orderId, userFrom(context));
    return "A cancellation request has been created for order %d and is awaiting review. "
            + "It has NOT been cancelled yet.".formatted(orderId);
}

The wording matters. Say plainly that it hasn’t happened, or the model will cheerfully report success. A human approves through your normal UI and your code executes the action — the model is never in the execution path for the irreversible part.

How to decide what needs a gate. Two questions:

Is it reversible? Reading data, drafting an email, generating a report — no gate. Sending the email, issuing the refund, deleting the record — gate it.

What’s the blast radius if it’s wrong a thousand times? Agents don’t make mistakes once. If a bug means it fires on every request, what happens?

A middle option worth knowing: tiered approval. Refunds under twenty pounds go through automatically, above that a human looks. Most of the value with most of the safety, and it’s how these systems tend to look once they mature.

Durable state

An agent run has state — the plan, completed steps, results so far. Where does it live?

The naive answer is a local variable in your service method, which works right up until the run takes ninety seconds and your pod is redeployed at second sixty.

Three options, increasing in robustness. In-memory is fine for short runs and gone on restart. Persisted run state — write each step’s result to a table as you go, with a run id and a status column — lets you resume from the last completed step and isn’t much work. A workflow engine (Temporal, or Spring Batch for simpler cases) gives you real durability, retries and visibility, and is worth it when runs are long or valuable.

Three things to get right regardless:

The general point: an agent run is a long-running business process. Everything you already know about those applies.

Putting it together

A production agent isn’t one clever prompt. It’s the whole series stacked up: a ChatClient whose provider is a property; an advisor chain carrying memory, retrieval, logging and guardrails; tools exposed over MCP with identity in ToolContext; retrieval scoped by a server-side metadata filter; approval gates on anything irreversible; a budget circuit breaker; and durable run state with a run id on every line.

Notice that almost none of that is AI-specific. It’s the same engineering you’d apply to any system that spends money and takes actions on a user’s behalf. The model is one component, and it’s the least trustworthy one.

Now let me talk you out of it

The biggest mistake in this field is reaching for an agent when a function would do.

Don’t use an agent when the steps are always the same. If every request goes extract → analyse → summarise, that’s a method. Writing it in Java makes it faster, cheaper, testable and debuggable. An agent that “decides” to do the same three things every time is an expensive way to hardcode.

Don’t use an agent when you can’t verify the output. If you have no way to tell whether a run succeeded, you have no way to detect it failing in production. You’ll find out from a customer.

Don’t use an agent for high-volume, low-value tasks. Agents are several model calls each. At a million requests a day that arithmetic is brutal.

Don’t use an agent when latency matters. Multi-step means multi-second, minimum.

Do use one when the shape of the work genuinely varies, when the value per task justifies several model calls, and when you have a way to check the result — a test suite, a schema, or a human.

The progression I’d actually recommend: start with one call. If that’s not enough, chain. If the chain needs to branch, route. Only when you genuinely cannot predict the steps should you hand control to the model.

Most production systems that describe themselves as AI agents are chaining and routing with good prompts. That isn’t a criticism — that’s what working systems look like.

Conclusion

Across this series the pattern that keeps repeating is that the hard parts aren’t the model. Provider portability is a property. Memory is a repository. Retrieval is a pipeline. Tools are annotated methods. Guardrails, caching and budgets are all the same advisor short-circuit wearing different hats.

What’s left after Spring AI removes the plumbing is ordinary engineering judgment: what to expose, what to constrain, what to verify, and what to escalate to a human. That’s the part worth spending your attention on — and it’s the part that doesn’t change when the models do.

If you build one thing from this series, make it small. One call, one clear job, one way to tell whether it worked. That’s a much better foundation than an agent you can’t debug.

More info: docs.spring.io/spring-ai/reference