Spring AI Tool Calling: Let Gemini Query Your Database in Java
Spring AI tool calling lets you hand Gemini a set of Java methods and let the model decide, on its own, when to call them. You annotate a method with @Tool, register it on the call, and the model can reach into your database, your APIs, or any other system the method touches, all without you writing a single line of dispatch logic.
That's the feature that turns an LLM from a text generator into something that can act. You describe what each method does, the model picks the right one for the question, and Spring AI handles the wiring: it generates schemas from your method signatures, sends them to the model, runs the methods the model asks for, and feeds the results back.
This is the third recipe in the Spring AI Recipes series. Recipe 02 taught the model to return structured Java records. This one teaches it to go get data first, from a real database, before it answers.
Quick Answer
To give Gemini a tool in Spring AI:
- Annotate a method with
@Tool(description = "...")and its parameters with@ToolParam(description = "...") - Make the containing class a Spring bean (
@Componentor@Service) - Register it on the call with
.tools(yourToolBean)
@Tool(description = "Check the current live status of a service component")
public ServiceStatus getServiceStatus(
@ToolParam(description = "The component, e.g. 'analytics', 'auth'") String component
) {
return repository.findStatus(component).orElse(/* unknown */);
}chatClient.prompt()
.user(question)
.tools(bugTools)
.call()
.content();The model reads the descriptions, decides which tools the question needs, and Spring AI runs them. It can call several tools in a single turn, reason over the combined results, and then answer.
What is tool calling (and why the description matters most)
The model never actually runs your code. What it does is decide, based on your tool descriptions, that a question needs a particular tool, and emit a structured request to call it. Spring AI intercepts that request, runs the real Java method, and sends the return value back to the model. The model then continues reasoning with the result in hand.
Two consequences follow from that, and they shape everything else in this recipe.
First, the tool description is the entire interface the model sees. It doesn't read your method body, and it has no idea what your SQL does. All it has is the description string and the parameter descriptions. Writing those well is prompt engineering, not documentation, and it's worth treating them that way.
Second, the model chooses. You don't write if (questionIsAboutStatus). You describe two tools clearly enough that the model can tell them apart, then you trust it to pick. The interesting behavior and the interesting failures both live in that choice.
Why a database makes this a better demo
Most tool-calling tutorials use a calculator or a hardcoded weather string. Those show the mechanism but hide the realism. A real database lookup shows what tool calling is actually for: letting the model reach into systems it has no training knowledge of and pull live, specific data.
This recipe builds a support-engineering assistant with two tools backed by an in-memory H2 database:
findSimilarBugssearches a history of past bug reportsgetServiceStatuschecks the current live status of a component
The seed data is rigged so the same area (analytics, and its export feature) shows up in both the bug history and as currently degraded in the status table. That overlap is what lets us watch the model decide between past and present.
Prerequisites
- A working Spring AI + Gemini setup (covered in recipe 01)
- Java 21+
- Maven 3.9+ (the project ships with the Maven wrapper)
- A free Gemini API key from aistudio.google.com/apikey
No external database. H2 runs in memory and seeds itself on startup.
Step 1: Write the tools
The two tools live in one class. Each method is annotated with @Tool, each parameter with @ToolParam. The descriptions draw a deliberate line between "right now" (status) and "seen before" (history), because that line is what the model uses to choose.
@Component
public class BugTools {
private final BugRepository repository;
public BugTools(BugRepository repository) {
this.repository = repository;
}
@Tool(description = """
Search the history of past bug reports for issues similar to a given
component name or keyword. Use this when the user asks whether a problem
has been seen before, how a past issue was resolved, or for prior context
on a component. Returns matching past bugs with their resolution status.
""")
public List<PastBug> findSimilarBugs(
@ToolParam(description = "A component name (e.g. 'analytics') or a keyword from the bug, e.g. 'export'")
String term
) {
System.out.println(">> Tool called: findSimilarBugs(term=" + term + ")");
return repository.findSimilar(term);
}
@Tool(description = """
Check the current live operational status of a service component. Use this
when the user asks whether something is currently broken, degraded, or down
right now. Returns the present status and a detail message. This is about the
present moment, not historical bugs.
""")
public ServiceStatus getServiceStatus(
@ToolParam(description = "The component to check, e.g. 'analytics', 'auth', 'billing'")
String component
) {
System.out.println(">> Tool called: getServiceStatus(component=" + component + ")");
return repository.findStatus(component)
.orElse(new ServiceStatus(component, "UNKNOWN", "No status record for this component"));
}
}The System.out.println in each method is there so you can watch, in the console, exactly which tools the model decides to call. It's the single most useful line for understanding what's happening.
Step 2: Wire the tools into the ChatClient
The tools become available to the model through one method call: .tools(bugTools). The system prompt explains the difference between the two tools in plain language, which reinforces the descriptions.
@Service
public class AssistantService {
private final ChatClient chatClient;
private final BugTools bugTools;
public AssistantService(ChatClient.Builder builder, BugTools bugTools) {
this.bugTools = bugTools;
this.chatClient = builder
.defaultSystem("""
You are a support engineering assistant for a software team.
You have two tools available:
- findSimilarBugs: searches the history of past bug reports
- getServiceStatus: checks the current live status of a component
A question about whether something is broken right now needs the live
status. A question about whether an issue has been seen before needs the
bug history. A question that asks both needs both. If neither is relevant,
answer directly. Keep answers grounded in what the tools return. Do not
invent statuses or bug IDs.
""")
.build();
}
public String ask(String question) {
return chatClient.prompt()
.user(question)
.tools(bugTools)
.call()
.content();
}
}That's the whole integration. No manual dispatch, no parsing of the model's tool requests, no loop to feed results back. Spring AI does all of it inside the single .call().
Step 3: Run it
The recipe reads the Gemini API key from an environment variable. Keep it in a .env file at the project root (gitignored) and source it before starting the app:
cp .env.example .env
# edit .env and paste in your real key
set -a && source .env && set +a
./mvnw spring-boot:runThe H2 database is created in memory and seeded from schema.sql and data.sql on startup. Nothing else to set up.
Step 4: Watch the model choose
This is the part worth slowing down for. Run each question and watch the console for the >> Tool called: lines.
A question about the past calls only findSimilarBugs:
curl -X POST http://localhost:8080/ask \
-H 'Content-Type: application/json' \
-d '{"question": "Have we seen problems with the analytics export before? How were they fixed?"}'A question about the present calls only getServiceStatus:
curl -X POST http://localhost:8080/ask \
-H 'Content-Type: application/json' \
-d '{"question": "Is the analytics service working right now?"}'In my runs, both of these picked exactly the right single tool every time, at temperature: 0.1. No misfires.
The parallel call, and the bug it exposed
Here is the question that earns the whole recipe:
curl -X POST http://localhost:8080/ask \
-H 'Content-Type: application/json' \
-d '{"question": "Is export broken right now, and have we hit this issue before?"}'The console showed two tool calls in the same turn:
>> Tool called: getServiceStatus(component=export)
>> Tool called: findSimilarBugs(term=export)
Parallel tool calling works. The model read a compound question, understood it needed both present status and past history, and dispatched both tools at once. Spring AI ran both and handed the results back. That's the moment tool calling stops feeling like a dressed-up if-statement and starts feeling like the model reasoning about what it needs.
But look at what came back:
I don't have current status information for 'export'. However, I found two similar bugs in the past: [...both analytics export bugs, with their fixes...]
The history tool worked. The status tool came back empty. And the reason is the most useful thing in this entire post.
The user asked about "export." The model passed component=export to getServiceStatus. But the service_status table is keyed by component (analytics, auth, billing), and there is no row called export. Export is a feature that lives under analytics. So the status lookup missed, returned UNKNOWN, and the model honestly reported that it had no status, rather than inventing one.
Meanwhile findSimilarBugs found the export bugs, because it does a fuzzy LIKE match against the bug summary text, where the word "export" actually appears.
Two tools, the same input term, two different outcomes, and it all comes down to how each tool matches against the data. This is the gap between what a user says and what your schema knows, and it's the single most common source of tool-calling disappointment in real systems. The model behaved perfectly. The data model was the problem.
What can go wrong with tool calling
1. The user's words don't match your schema's keys
This is the bug above, and it is the one that will bite you most. Users say "export," your table says "analytics." Users say "can't log in," your component is "auth." The model faithfully passes through what the user said, and your exact-match lookup misses.
The fixes are all at the data and tool layer, not the model layer. You can make the lookup fuzzy (LIKE matching, as findSimilarBugs does). You can add a synonym or alias table. You can give the tool a description that lists the valid component names so the model maps "export" to "analytics" itself. The point is that "the tool returned nothing" is usually a schema-mismatch problem, not a model problem.
2. Vague descriptions cause wrong-tool selection
The model's tool choice is only as good as the descriptions. If two tools have overlapping or fuzzy descriptions, the model will call the wrong one on ambiguous questions. Sharpen the descriptions before you blame the model. Editing a description to be vaguer and watching selection accuracy drop is the fastest way to feel how much the model leans on those strings.
3. The model fabricates instead of calling a tool
At higher temperatures, the model may answer from training knowledge instead of calling the tool. Lower the temperature (this recipe uses 0.1) and make the system prompt explicit that answers must be grounded in tool output.
4. @Tool methods not detected
If the model never calls your tools, confirm the tool object is a Spring bean and is passed via .tools(...) or .defaultTools(...). Spring AI 1.1 had reported issues detecting @Tool methods in certain registration patterns (issue #5134).
Frequently asked questions
What is the difference between tool calling and function calling in Spring AI?
They are the same concept. Older Spring AI versions (pre-1.0.0-M6) used the term "function calling" and the FunctionCallback API. Current versions use "tool calling," the @Tool annotation, and the ToolCallback API. "Tools" is the industry-standard term; the rename aligned Spring AI with it.
How does the model decide which tool to call?
It reads the @Tool description and @ToolParam descriptions, compares them to the user's question, and decides. You don't write any code path for the decision itself. That's why the descriptions matter more than anything else in the setup.
Can Gemini call multiple tools at once?
Yes. As shown above, a compound question made Gemini call both getServiceStatus and findSimilarBugs in a single turn. Spring AI runs all requested tools and returns the combined results to the model before it produces a final answer.
Do I have to parse the tool call myself?
No. Spring AI handles the entire loop inside .call(): generating tool schemas, sending them to the model, receiving tool-call requests, running the Java methods, and feeding results back. You write the tool methods and register them, and that's it.
Why did my tool return nothing for a valid-sounding question?
Most likely the argument the model passed (taken from the user's wording) didn't match your data's keys. The user says "export," your table is keyed by "analytics." Make the lookup fuzzy, add aliases, or list valid values in the tool description.
Can a Spring AI tool query a real database?
Yes, and that's exactly what this recipe does. A tool method is ordinary Java, so it can inject a repository and run a real query. In this recipe the tools hit an in-memory H2 database through Spring's JdbcClient, but the same pattern works with Postgres, MySQL, or any data source your Spring app already uses. The model decides what to ask for; your normal data layer answers.
What's next
Recipe 04 takes on provider portability: running the exact same tool-calling code against a different model by changing configuration, not Java. The promise of Spring AI's abstraction is that you can swap Gemini for another provider without touching your tools, your service, or your controller. Recipe 04 puts that promise to the test and shows where it holds up and where the provider-specific edges leak through.
Why tool calling matters for Java teams
Tool calling is where the Java ecosystem's maturity pays off most. The model's job is to decide what to call. Everything downstream of that decision is ordinary backend engineering: a service method, a repository, a database query, a transaction boundary, an error to handle. That's work Java and Spring have done well for fifteen years.
The thing the model reaches into is your existing system, with all its existing safety. Dependency injection wires the tool to the repository. The repository runs a parameterized query. Spring manages the transaction. The model never touches any of it directly; it just asks, and your stack answers in the way it always has. Python can do this too, but if your systems already run on the JVM, tool calling lets the model use them without you rebuilding anything.
Working code
The complete project for this tutorial is on GitHub:
github.com/TheRavi/spring-ai-recipes (the 03-tool-calling/ folder)
Clone it, copy .env.example to .env, add your Gemini key, and run.
Further reading
- Spring AI tool calling reference
- Spring AI Google GenAI chat reference
- @Tool annotation Javadoc
- Get a free Gemini API key
Related posts on this blog:
- Spring AI Structured Output: Typed Java Records from Gemini in One Line. Recipe 02, the structured output this assistant builds on.
- Spring AI + Gemini: Your First API Call in Java. Recipe 01, the hello-world setup.
Hit a tool-calling behavior this post didn't cover? Open an issue on the repo or reach out on LinkedIn. The schema-mismatch problem in particular has a dozen variations, and I'd like to collect the ones that show up in real systems.