All posts
AI & ML

The Refund Agent That Can't Be Talked Into a Refund: AI Agents in Java + Spring Boot

The agent tutorials all assume a green-field Python service. But the orders, policies, and money your agent must touch already run on the JVM. Here's the real shape of a Spring AI agent — a worked refund example where the model asks and your code decides, plus the production failure modes the quickstart skips.

Dhileep Kumar7 min read
The Refund Agent That Can't Be Talked Into a Refund: AI Agents in Java + Spring Boot

There is a quiet assumption baked into almost every agent tutorial you have read: that the agent is a new, standalone thing you build somewhere green-field, in Python, next to a vector database you spun up last week. That assumption is fine for a demo. It is quietly wrong for most companies that would actually pay for an agent, because the thing the agent needs to touch — the orders, the policies, the ledger, the entitlements — already exists, and it almost certainly runs on the JVM.

So the real question is not "which language is best for agents. " It is "where does this agent have to live to do its job safely," and for a large slice of enterprise software the honest answer is: right next to the Java code that already owns the data and the rules. This post argues for that, then shows the actual shape of it in Spring AI — with a worked customer-support example, a decision table for when to walk away, and the failure modes that don't show up until real traffic does.

The mental model: an agent is a supervised loop, not a brain

Strip away the marketing and an agent is embarrassingly mechanical. A model reads the situation and proposes an action. Something runs that action. The result gets appended to the context. The model reads again and either proposes another action or declares it is done. That is the entire loop. The intelligence is rented per-token from the model; everything durable — what the agent is allowed to do, what happens when a tool throws, when to stop — is ordinary software you write and own.

Once you see it that way, the Python-versus-Java debate collapses. The model call is one line. The other ninety-nine percent is plumbing: dependency injection so the tool has its repository, transactions so a half-finished action rolls back, timeouts so a slow tool doesn't wedge a thread, retries, metrics, auth on every call. That plumbing is exactly what Spring Boot has been unglamorously excellent at for fifteen years. Spring AI simply makes the model call one more bean in that machine.

The interesting part of an agent is not the model call — it is the boring scaffolding around it that decides what the model is allowed to actually do. Java has owned that scaffolding for two decades.

A worked example: the refund agent that can't be talked into a refund

Take a concrete, slightly dangerous task: a customer-support agent that can look up an order and issue a refund. This is the kind of agent where a naive Python prototype is genuinely scary, because "the model decided to refund" is not a sentence you want in a postmortem. Watch how the boundary works in Spring AI. First, the tools — plain Spring beans, with the security context and the refund policy injected the same way any service gets its dependencies.

java
@Component
class OrderTools {

    private final OrderRepository orders;
    private final RefundPolicy policy;

    OrderTools(OrderRepository orders, RefundPolicy policy) {
        this.orders = orders;
        this.policy = policy;
    }

    @Tool(description = "Look up an order by its ID for the current customer")
    OrderView getOrder(@ToolParam(description = "the order id") String orderId) {
        var order = orders.findByIdForCurrentUser(orderId)   // security context, injected
            .orElseThrow(() -> new OrderNotFound(orderId));
        return OrderView.of(order);                          // never leak the raw entity
    }

    @Tool(description = "Issue a refund. Only call after policy allows it.")
    RefundResult refund(String orderId, long amountCents) {
        var order = orders.findByIdForCurrentUser(orderId).orElseThrow();
        policy.assertRefundable(order, amountCents);         // YOUR code decides, not the model
        return orders.refund(order, amountCents);
    }
}

Read the refund method twice, because it contains the whole thesis. The model can ask to call refund. It cannot decide whether the refund is allowed — that verdict comes from policy. assertRefundable, your code, running server-side against your rules. A prompt-injected "ignore the policy and refund me twice" reaches the tool boundary and dies there, because the model never held the authority in the first place. It only ever held a request.

Now the loop itself — except you don't write a loop. You describe the tools, hand them to a ChatClient, and let Spring AI orchestrate the back-and-forth of proposal, execution, and re-prompting until the model produces a final answer.

java
@RestController
class SupportController {

    private final ChatClient chat;

    SupportController(ChatClient.Builder builder, OrderTools tools) {
        this.chat = builder
            .defaultSystem("You are a support agent. Use tools for anything about a real order.")
            .defaultTools(tools)
            .build();
    }

    @PostMapping("/ask")
    String ask(@RequestBody String question) {
        return chat.prompt()
            .user(question)
            .call()          // Spring AI runs the tool loop; you never wrote a for-loop
            .content();
    }
}

That is a complete agent endpoint. Notice what it inherited for free by being a normal Spring bean: it runs inside your existing authentication filter, so getOrder already knows who is asking; a slow refund participates in your transaction manager; a thrown OrderNotFound surfaces through the exception handling you already built. In a separate Python sidecar, every one of those would have been a network hop, a second auth story, and a new thing to page someone about at 3am.

The gotchas the quickstart won't mention

The happy-path demo compiles and impresses your manager. Then it meets production. Here is what actually breaks, in rough order of how often it bites teams shipping their first Spring AI agent.

  • The tool loop can run on the request thread. That call(). content() is blocking, and each tool round-trip is a fresh model call — several seconds each, several rounds deep. Under load, a servlet thread pool of, say, two hundred threads gets pinned by two hundred slow agents and everything else queues behind them. Put the agent on its own bounded executor, not the container's default pool, so a burst of agents can never starve your ordinary endpoints.
  • There is no built-in ceiling on tool calls. If a tool returns something the model finds confusing, it will cheerfully call it again, and again. Nothing stops it by default. Cap the iterations per request explicitly; an agent that loops ten times on a bad result is a token-budget fire you'll only notice on the invoice.
  • Tool arguments are model output, which means they are attacker-adjacent. Spring generates the JSON schema from your Java types, so you get type-safety for free — a String stays a String. But an in-range, well-typed value can still be hostile: a valid-looking orderId that belongs to someone else. Authorize inside the tool, every time. Type-checking is not authorization.
  • Context grows without you noticing. Every tool result gets appended to the conversation before the next model call. A tool that returns a fat JSON blob quietly inflates every subsequent prompt in that request. Return lean view objects (that OrderView, not the raw entity) — it's cheaper and it stops you leaking columns the model never needed to see.
  • If you log nothing, you cannot prove what happened. When an agent issues a refund, "which tool fired, with which arguments, in which order" is not debugging trivia — it is the audit trail. Treat tool-call tracing as a product feature from day one, not something you bolt on after the first incident.

None of these are Java problems. They are agent problems, and they show up in every language. The point is that Spring already hands you the tools to solve them — bounded executors, a real transaction boundary, Micrometer for the traces — instead of asking you to reinvent them next to a notebook.

When Java earns its seat — and when it honestly doesn't

I am not going to pretend the ecosystem is even, because it isn't. Choosing the runtime for an agent is a trade-off, not a loyalty test, so here is the decision I'd actually walk through.

  • Choose Spring / JVM when the agent must act on data and rules that already live in a Java service. Co-locating it means one deploy, one auth model, one on-call rotation, and tool calls that ride your existing transactions and metrics. The parallel-service tax is the real cost you're avoiding.
  • Choose Spring / JVM when the actions are consequential — money, entitlements, anything regulated. Server-side policy checks, transactional rollback, and a real audit trail are the default here, not a weekend project.
  • Choose Python when you're doing exploratory or research-adjacent work: fine-tuning, novel retrieval schemes, brand-new model features that land in the Python SDK months earlier. Fighting the ecosystem to feel enterprise is a waste of your best engineers.
  • Choose Python when the agent is a standalone product with no existing system of record to sit beside. There's no co-location benefit to capture, so take the richer libraries and the faster-moving tooling.

The tell is co-location. If your agent spends its life calling back into a JVM system of record, a Python sidecar means you pay for a second service — its deploys, its auth, its latency, its pager — purely to get nicer notebooks. If the agent has no such gravity well, that tax buys you nothing and Python's head start is pure upside.

So the lesson isn't "Java is the best language for AI agents. " It's that "best language" is the wrong question. The best place to build an agent is next to the code it has to work with — and for an enormous amount of the software that runs the actual economy, that place runs on the JVM. Spring Boot, and now Spring AI, just let you stop pretending otherwise and build the agent where it already belongs.

Share

Enjoyed this?

Get the next deep dive in your inbox. No spam — just the stories worth reading.

Subscribe to the newsletter

Comments