Before you install any framework, before you add any dependency — you should make one raw HTTP call to the model yourself. Just to see what it actually is.
That's how I started. And it's how I'd recommend everyone starts.
Step 1 — Get a Gemini API key
Go to Google AI Studio and create a free API key. No credit card needed. Gemini has a generous free tier — more than enough to learn and build.
Once you have the key, store it as an environment variable. Never paste it directly into your code.
GEMINI_API_KEY=your_key_here
Step 2 — Understand what you're calling
Gemini exposes a REST API. The endpoint looks like this:
POST https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=YOUR_API_KEY
You send a JSON body, you get a JSON response. That's it. No magic.
The request body looks like this:
{
"contents": [
{
"role": "user",
"parts": [{ "text": "What is a token in LLMs?" }]
}
]
}
And the response comes back like this:
{
"candidates": [
{
"content": {
"parts": [{ "text": "A token is a piece of text..." }],
"role": "model"
}
}
]
}
Your answer is at candidates[0].content.parts[0].text. Navigate that JSON tree and you have your response.
Step 3 — Build it with plain Java, no frameworks
Here's a working Spring Boot controller that calls Gemini using nothing but Java's built-in HttpClient and Jackson (which Spring Boot already includes). No Spring AI. No extra dependencies.
pom.xml — intentionally minimal:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
That's the only dependency. Spring Boot 3.x includes Jackson. Java 11+ includes HttpClient. Nothing else needed.
application.properties:
gemini.api.key=${GEMINI_API_KEY}
gemini.api.url=https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent
ChatController.java:
@RestController
@RequestMapping("/api/chat")
public class ChatController {
@Value("${gemini.api.key}")
private String apiKey;
@Value("${gemini.api.url}")
private String apiUrl;
private final ObjectMapper objectMapper = new ObjectMapper();
private final HttpClient httpClient = HttpClient.newHttpClient();
@PostMapping
public ResponseEntity<String> chat(@RequestBody String userMessage) throws Exception {
// Build the request body
String requestBody = """
{
"contents": [
{
"role": "user",
"parts": [{ "text": "%s" }]
}
]
}
""".formatted(escape(userMessage));
// Make the HTTP call
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(apiUrl + "?key=" + apiKey))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(requestBody))
.build();
HttpResponse<String> response = httpClient.send(request,
HttpResponse.BodyHandlers.ofString());
// Parse the response — answer is at candidates[0].content.parts[0].text
JsonNode root = objectMapper.readTree(response.body());
if (root.has("error")) {
return ResponseEntity.status(500)
.body(root.path("error").path("message").asText());
}
String reply = root
.path("candidates").get(0)
.path("content")
.path("parts").get(0)
.path("text").asText();
return ResponseEntity.ok(reply);
}
private String escape(String text) {
return text
.replace("\\", "\\\\")
.replace("\"", "\\\"")
.replace("\n", "\\n")
.replace("\r", "\\r");
}
}
Start the app, send a POST request to /api/chat with a message body, and you'll get a response from Gemini.
That's a fully working AI chat endpoint. No AI framework. Just HTTP.
Full source code for this phase: github.com/shamprakash2000/gemini-chat/tree/phase-1-2-plain-http
What you just built — and what's missing
This works. Send a message, get a response. That's a real AI endpoint.
But look at what you're doing manually:
- Building JSON by hand — string formatting, escaping characters, constructing the payload yourself
-
Parsing JSON by hand — navigating
.path().get().path()to extract the answer - No model switching — the URL and response format are Gemini-specific. Switching to Claude means rewriting everything
For one simple call, this is fine. But the moment you want conversation memory, tool calling, streaming, or RAG — you're building a framework from scratch.
That's where Spring AI comes in.
What Spring AI actually is
Spring AI is the Spring team's answer to: "we keep writing the same boilerplate to call LLMs — let's standardise it."
It gives you:
A common interface across models. The same ChatClient code works for Gemini, OpenAI, Claude, Ollama. You swap the dependency and config — the Java code stays the same.
No more manual JSON. The framework builds the request payload and parses the response for you.
Conversation memory, tool calling, structured output, streaming — all built in. We'll get to each of these in the articles ahead. For now, just understand what the foundation gives you.
The same endpoint, rewritten with Spring AI
Add the dependency:
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-bom</artifactId>
<version>1.1.8</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-google-gemini</artifactId>
</dependency>
Update application.properties:
spring.ai.google.gemini.api-key=${GEMINI_API_KEY}
spring.ai.google.gemini.chat.options.model=gemini-2.5-flash
Now the controller:
@RestController
@RequestMapping("/api/chat")
public class ChatController {
private final ChatClient chatClient;
public ChatController(ChatClient.Builder builder) {
this.chatClient = builder
.defaultSystem("You are a helpful assistant.")
.build();
}
@PostMapping
public String chat(@RequestBody String userMessage) {
return chatClient.prompt()
.user(userMessage)
.call()
.content();
}
}
Same functionality. A fraction of the code.
What Spring AI does not protect you from
I want to be honest here because most tutorials aren't.
The API changes between minor versions.
I was on Spring AI 1.0.x and upgraded to 1.1.x. Two things broke silently — no compiler errors, no deprecation warnings. Just runtime failures. The ChatClient builder API had changed, and the way conversation memory was configured had moved to a different class entirely.
Lesson: when you upgrade Spring AI, read the full changelog and test every integration point. Don't assume a minor version bump is safe.
The abstraction hides internals.
The plain HTTP version showed you exactly what was happening — every byte in, every byte out. Spring AI hides that. When something goes wrong deep in the framework, you need to know what's underneath to debug it. That's another reason to start with plain HTTP first — so you have that mental model when the abstraction breaks.
Plain HTTP vs Spring AI — the honest comparison
| Plain HTTP | Spring AI | |
|---|---|---|
| Dependencies | None beyond Spring Web | Spring AI starter |
| Code volume | High | Low |
| JSON handling | Manual | Automatic |
| Model switching | Full rewrite | Swap config |
| Tool calling / agents | Build from scratch | Built in |
| Debugging | Full visibility | Abstraction hides internals |
| Version stability | Stable (it's just HTTP) | Breaking changes between minors |
| Good for | Learning, full control | Production Spring Boot apps |
Start with plain HTTP. Understand what you're abstracting. Then move to Spring AI.
What's next
Spring AI is set up and working. Next: building a proper chat application — with persistent conversation history in PostgreSQL, session management, and a system prompt that shapes the model's behaviour.
Did you hit a breaking change in Spring AI between versions? Drop it in the comments — you're not the only one.
Sham Prakash K — Backend Engineer, 4+ years in Java, Spring Boot, and distributed systems. Building AI backend infrastructure. Writing about what I actually learned, mistakes included.
Top comments (0)