Let's get started with a Microservice Architecture with Spring Cloud:
Introduction to Google GenAI Chat and Spring AI
Last updated: August 7, 2026
1. Introduction
The generative AI landscape is evolving rapidly, pushing framework maintainers to rethink how applications connect to diverse large language models (LLMs). With the release of Spring AI, Java developers gained a unified, portable interface to interact with cognitive services.
However, as the ecosystem matured, integrating multiple foundational models within a single runtime classpath introduced configuration conflicts. To solve this, Spring AI introduced major architectural updates. These include the specialized spring-ai-starter-model-google-genai starter module and an explicit model selection paradigm.
In this tutorial, we’ll explore how to integrate Google’s Gemini models into a Spring Boot application using the Gemini Developer API (via Google AI Studio). We’ll walk through a streamlined project setup, dive into text generation patterns using both low-level and high-level APIs, apply prompt templates, enable live web grounding, and stream tokens reactively with WebFlux.
2. Project Setup
To get started, we need a standard Spring Boot 3.x application. Because Spring AI modules are actively updated, it’s highly recommended to manage dependency versions using the Spring AI Bill of Materials (BOM).
2.1. Maven Dependencies
First, let’s configure the Spring AI Starter Google GenAI and Spring AI BOM in our pom.xml file. Since Spring AI artifacts are hosted in the Spring Milestones repository during release cycles, we ensure both the repository and dependency management blocks are properly declared:
<project>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-bom</artifactId>
<version>2.0.0</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-google-genai</artifactId>
</dependency>
</dependencies>
</project>
2.2. Activating the Chat Provider via spring.ai.model.chat
In the latest versions of Spring AI, dropping a starter module onto the classpath no longer automatically activates it. This prevents runtime configuration conflicts when your application references multiple LLM providers.
We must explicitly declare our active chat provider inside our application.properties file:
spring.ai.model.chat=google-genai
spring.ai.google.genai.chat.model=gemini-2.5-flash
spring.ai.google.genai.api-key=${SPRING_AI_GOOGLE_GENAI_API_KEY}
Obtain an API key from the Google AI Studio. Using this placeholder syntax explicitly documents the application’s external runtime dependency while preventing hardcoded secrets from leaking into our source control repository.
2.3. Environment Setup
We can satisfy the property placeholder defined above without writing any custom Java decryption or binding code. We’ll simply export the credentials directly into our environment. The auto-configuration engine will automatically resolve the placeholder value at startup:
export SPRING_AI_GOOGLE_GENAI_API_KEY="YourActualSecureGoogleAIStudioKeyHere"
Once this environment variable is set, Spring Boot seamlessly bridges the variable to the properties file placeholder, making the GoogleGenAiChatModel ready for injection.
3. Core Text and Content Generation Strategies
Spring AI gives us two primary abstraction layers for communicating with Gemini models: the foundational ChatModel bean and the highly configurable, fluent ChatClient API. We isolate our interaction logic into a dedicated service layer to follow enterprise design patterns and keep code clean. Then, we expose these capabilities through standard Spring REST controllers.
3.1. Injecting and Using GoogleGenAiChatModel
The GoogleGenAiChatModel represents the foundational, low-level client abstraction. It handles request serialization, HTTP execution against Google’s RPC endpoints, and raw response parsing.
Let’s look at our ChatService, where we inject this bean along with a ChatClient.Builder to initialize our operational layers:
@Service
public class ChatService {
private final GoogleGenAiChatModel chatModel;
private final ChatClient defaultChatClient;
private final ChatClient fluentChatClient;
public ChatService(GoogleGenAiChatModel chatModel, ChatClient.Builder chatClientBuilder) {
this.chatModel = chatModel;
this.defaultChatClient = chatClientBuilder.build();
this.fluentChatClient = chatClientBuilder
.defaultSystem("You are a concise technical writer summarizing software concepts.")
.build();
}
public String simplifiedPrompt(String message) {
return chatModel.call(message);
}
}
When we invoke chatModel.call(message), the framework packages the raw string into a default prompt context. It then sends this to the configured model variant, extracts the text block from the response payload, and returns it. We expose this in our ChatController:
@RestController
public class ChatController {
private final ChatService chatService;
public ChatController(ChatService chatService) {
this.chatService = chatService;
}
@GetMapping("/v1/chat/simple")
public String simplifiedPrompt(@RequestParam(defaultValue = "Hello") String message) {
return chatService.simplifiedPrompt(message);
}
}
This establishes a simple string-in, string-out endpoint. It uses the lowest-level model client abstraction available to verify basic connectivity with the Gemini engine.
3.2. Constructing Prompts With the Fluent ChatClient API
While working directly with the model handles quick operations, production architectures favor the fluent ChatClient API. The ChatClient acts as a facade layer that simplifies prompt assembly and attaches default configuration baselines.
As shown in our service constructor, we pre-configured a specialized fluentChatClient with system instructions. Let’s add the corresponding execution method to ChatService:
public String fluentPrompt(String prompt) {
return this.fluentChatClient.prompt()
.user(prompt)
.call()
.content();
}
By routing requests through this pre-configured instance, every user query automatically appends the target system instructions before execution. We expose this endpoint in the controller:
@GetMapping("/v1/chat/fluent")
public String fluentPrompt(@RequestParam String prompt) {
return chatService.fluentPrompt(prompt);
}
This allows us to encapsulate recurring personas or systemic behavior directly within the client wrapper instance. As a result, we don’t need to modify individual incoming text arguments manually.
3.3. Handling Dynamic Inputs via Prompt Templates
Hardcoding parameter logic directly inside strings leads to messy string concatenation and fragile codebases. Spring AI solves this with structured prompt templates, separating instructions from user variables.
Let’s implement a code review capability inside our ChatService using a multiline text block placeholder layout:
public String reviewCode(String language, String codeSnippet) {
String template = """
Analyze the following {language} code snippet for memory leaks or inefficiencies.
Provide an optimized version.
Code:
{code}
""";
return this.defaultChatClient.prompt()
.user(u -> u.text(template).params(Map.of("language", language, "code", codeSnippet)))
.call()
.content();
}
The client replaces the target tags ({language}, {code}) at runtime, formatting the text cleanly before submission. We expose this via a POST request wrapper in our controller:
@PostMapping("/v1/chat/review")
public String reviewCode(@RequestParam(defaultValue = "Java") String language, @RequestBody String codeSnippet) {
return chatService.reviewCode(language, codeSnippet);
}
This pattern cleanly decouples structural prompt engineering boundaries from volatile business data, producing a highly reusable, parameter-driven invocation pattern.
3.4. Grounding Responses With Live Google Search Retrieval
LLMs naturally suffer from training cut-off windows and knowledge gaps regarding live, real-world developments. The Google GenAI module exposes an explicit grounding property to seamlessly connect our model with live Google Search indexing.
To activate live search grounding across our entire application, we need to toggle the grounding flag to true inside our configuration properties:
spring.ai.google.genai.chat.google-search-retrieval=true
When this property is enabled, queries regarding current events or breaking news are automatically grounded using fresh search results. Let’s write an endpoint to handle real-time informational requests:
public String searchGroundedPrompt(String currentEventQuery) {
return this.defaultChatClient.prompt()
.user(currentEventQuery)
.call()
.content();
}
We map this method to our HTTP layer inside our controller configuration:
@GetMapping("/v1/chat/grounded")
public String searchGroundedPrompt(@RequestParam String currentEventQuery) {
return chatService.searchGroundedPrompt(currentEventQuery);
}
If we pass a request like “Who won the most recent football tournament match yesterday?”, the model uses live Google Search indexing to anchor its response in verified facts, drastically reducing hallucinations. This configuration bridges the gap between static training cutoffs and live, real-world events.
3.5. Processing Real-Time Streaming Responses With Flux
For user-facing interfaces, waiting for a long text block to fully generate on the server results in noticeable latency. Instead, we can stream chunks back to the user, token by token, using Spring Boot’s WebFlux integration.
Let’s add a streaming method to our ChatService that returns a reactive Flux<String> structure:
public Flux<String> streamChatTokens(String prompt) {
return this.defaultChatClient.prompt()
.user(prompt)
.stream()
.content();
}
We’ll create a specialized StreamingChatController to deliver these tokens cleanly over an open connection pipeline. This controller explicitly produces a text/event-stream media response:
@RestController
public class StreamingChatController {
private final ChatService chatService;
public StreamingChatController(ChatService chatService) {
this.chatService = chatService;
}
@GetMapping(value = "/v1/chat/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<String> streamChatTokens(@RequestParam String prompt) {
return chatService.streamChatTokens(prompt);
}
}
When hitting this endpoint, consumers read token data reactively. This establishes a non-blocking execution pipeline that builds an interactive, low-latency UI flow chunk by chunk.
4. Testing
Testing applications that rely on external generative AI endpoints requires a clear separation of concerns. Running tests shouldn’t trigger external network calls, hit rate limits, or exhaust API quotas during local compilation. To prevent this, we isolate our HTTP layer using Spring Boot’s slice testing along with @MockitoBean.
4.1. Unit Testing via WebLayer MockMvc Mocking
Since our implementation encapsulates all LLM communications within ChatService, we can completely mock this business component. This permits us to cleanly assert controller routing, request parameters, response body expectations, and stream-handling patterns.
Let’s write an isolated test using MockMvc to verify all text-based, template-driven, and reactive stream endpoints without triggering an actual network handshake. First, we set up our simple prompt test to verify basic GET parameter routing and raw response mapping:
@SpringBootTest(properties = {
"spring.ai.model.chat=google-genai",
"spring.ai.google.genai.chat.model=gemini-2.5-flash",
"spring.ai.google.genai.api-key=test-key"
})
@AutoConfigureMockMvc
class ChatControllerUnitTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private ChatService chatService;
@Test
void whenSimpleEndpointIsInvoked_thenReturnsMockedModelResponse() throws Exception {
when(chatService.simplifiedPrompt(anyString())).thenReturn("Mocked Gemini Response");
mockMvc.perform(get("/v1/chat/simple").param("message", "Hello"))
.andExpect(status().isOk())
.andExpect(content().string("Mocked Gemini Response"));
verify(chatService).simplifiedPrompt("Hello");
}
}
Next, we confirm that our fluent prompt endpoint forwards query parameters accurately to the underlying service. Then it returns the configured system-prompt-backed response:
@Test
void whenFluentEndpointIsInvoked_thenReturnsModelContent() throws Exception {
when(chatService.fluentPrompt(anyString())).thenReturn("Fluent Response");
mockMvc.perform(get("/v1/chat/fluent").param("prompt", "Explain DI"))
.andExpect(status().isOk())
.andExpect(content().string("Fluent Response"));
verify(chatService).fluentPrompt("Explain DI");
}
For our template-driven endpoint, we validate that the controller correctly processes a multipart payload consisting of query parameters and a text/plain request body:
@Test
void whenReviewEndpointIsInvoked_thenReturnsModelContent() throws Exception {
when(chatService.reviewCode(anyString(), anyString())).thenReturn("Optimized Code");
mockMvc.perform(post("/v1/chat/review")
.param("language", "Java")
.contentType(MediaType.TEXT_PLAIN)
.content("class A { }"))
.andExpect(status().isOk())
.andExpect(content().string("Optimized Code"));
verify(chatService).reviewCode("Java", "class A { }");
}
We’ll now verify that search-grounded queries pass their parameters cleanly through the controller layer without parameter binding errors:
@Test
void whenGroundedEndpointIsInvoked_thenReturnsModelContent() throws Exception {
when(chatService.searchGroundedPrompt(anyString())).thenReturn("Grounded Response");
mockMvc.perform(get("/v1/chat/grounded").param("currentEventQuery", "latest match winner"))
.andExpect(status().isOk())
.andExpect(content().string("Grounded Response"));
verify(chatService).searchGroundedPrompt("latest match winner");
}
Finally, we test our reactive endpoint to ensure Spring MVC properly negotiates the text/event-stream media type. It will also format the reactive Flux elements into standard Server-Sent Event (data:) frames:
@Test
void whenStreamEndpointIsInvoked_thenReturnsEventStreamContent() throws Exception {
when(chatService.streamChatTokens(anyString()))
.thenReturn(reactor.core.publisher.Flux.just("token-1", "token-2"));
mockMvc.perform(get("/v1/chat/stream").param("prompt", "Stream tokens"))
.andExpect(status().isOk())
.andExpect(content().contentTypeCompatibleWith(MediaType.TEXT_EVENT_STREAM))
.andExpect(content().string("data:token-1\n\ndata:token-2\n\n"));
verify(chatService).streamChatTokens("Stream tokens");
}
By leveraging the modern @MockBean annotation, we inject our mock definitions directly into the application context wrapper, replacing the real bean configuration. This allows us to stub out standard synchronous operations as well as complex reactive Flux stream responses safely and reliably without network overhead.
5. Conclusion
In this tutorial, we configured a Spring Boot application using Spring AI’s updated spring-ai-starter-model-google-genai engine to interact directly with Google AI Studio.
We saw how explicitly defining spring.ai.model.chat=google-genai resolves modern multi-model classpath dependencies. From there, we established a clean architecture using a dedicated ChatService. We built out flexible prompt workflows with the fluent ChatClient API and isolated dynamic parameters using templates. Finally, we enabled live web grounding and streamed responsive outputs via WebFlux Flux.
As always, the complete code samples used in this article are available over on GitHub.
















