Google Gemini with Spring AI 2.0

I spent some time wiring Google Gemini into Spring AI 2.0, mostly just to see how smooth the on-ramp really is. Turns out the integration story tells you a lot about where Spring AI is headed. Curious if it matches what you're seeing too.

Google has kept pushing the Gemini Developer API hard, and the pricing on the Flash-tier models makes it genuinely competitive for high-volume, low-latency chat completions. Gemini 2.5 Flash is the one I actually used for this test, not the older 2.0 line most tutorials still reference. Résumé parsing, job fit summaries, quick candidate assessments: none of that needs a frontier reasoning model burning through your budget. It needs something fast, cheap, and good enough at structured extraction. Spring AI, meanwhile, has matured into something I trust for production work. I was skeptical at first. I've been burned by "unified abstraction" layers that leak provider quirks the moment you need something custom. The 2.0 release cleaned up a lot of that friction, and the headline feature that actually matters to me isn't the Gemini support, it's the first-class Model Context Protocol integration. MCP lets you expose tools and resources to a model in a standardized way instead of hand-rolling function calling glue for every provider. I haven't wired that into ITJobOpportunities yet, but it's the first thing I'm going to poke at once this Gemini branch is done. Setting up the dependency If you're starting from a normal Spring Boot project, Gemini support comes through the spring-ai-starter-model-google-genai starter. Google consolidated its Vertex AI and Gemini Developer API paths under one GenAI SDK a while back, which cleaned up what used to be two separate client libraries with two separate quirks. <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-starter-model-google-genai</artifactId> </dependency> Pull in the Spring AI 2.0 BOM too, so version alignment doesn't turn into a debugging session at 11pm: <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> Grab an API key from Google AI Studio (free tier friendly for testing) and drop it into properties: spring: ai: google: genai: api-key: ${GOOGLE_GENAI_API_KEY} chat: options: model: gemini-2.5-flash That's the whole wiring step. No custom RestClient config, no manual JSON marshaling. Spring Boot's auto-configuration picks up the properties and hands you an autowired ChatModel bean, same pattern whether you're pointing at Gemini, OpenAI, or Anthropic through their respective starters. The actual chat call This is where Spring AI earns its keep. Inject a ChatClient.Builder , build a client, send prompts: @RestController @RequestMapping("/api/gemini") public class GeminiChatController { private final ChatClient chatClient; public GeminiChatController(ChatClient.Builder builder) { this.chatClient = builder.build(); } @GetMapping("/ask") public String ask(@RequestParam String question) { return chatClient.prompt() .user(question) .call() .content(); } } Nine lines, and I had a working Gemini-backed endpoint. I half expected an auth handshake to break, that's usually where these integrations fall apart, but it just worked. Not always the case with new major-version starters, so I'll take the win. Structured output is the part that actually matters Free-form chat completions are a fine demo, but nobody building anything real wants a wall of text back from a model. Every AI feature we ship on ITJobOpportunities, whether it's the Job Fit score on a job detail page or a candidate assessment a recruiter triggers in the ATS, needs structured, parseable output. Spring AI's .entity() mapping handles this cleanly: public record CandidateSkillMatch( List<String> matchedSkills, List<String> missingSkills, int fitScore, String summary ) {} @PostMapping("/fit-check") public CandidateSkillMatch checkFit(@RequestBody FitCheckRequest request) { return chatClient.prompt() .user(u -> u.text(""" Compare this resume against the job requirements. Resume: {resume} Job requirements: {requirements} Return matched skills, missing skills, a fit score 0-100, and a short summary. """) .param("resume", request.resumeText()) .param("requirements", request.jobRequirements())) .call() .entity(CandidateSkillMatch.class); } This is roughly the shape of our real Job Fit feature, minus the async job queue and SSE streaming we layer on top of it. But the core call, prompt in, structured record out, is exactly this simple. That's the part that took me longest to trust when I first started building AI features for the platform: believing the model will respect your schema often enough to build a reliable feature on top of it. It mostly does. Gemini 2.5 Flash was decent at this in my testing, noticeably better than the 2.0 generation at holding to a strict schema on longer résumé text, though I still validate before anything touches the database. Never trust an LLM's structured output blindly, no matter how good the benchmark numbers look. Streaming responses For anything user-facing where latency matters, and in hiring tech, candidates get impatient fast, you want streaming instead of waiting on the full completion: @GetMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) public Flux<String> streamAnswer(@RequestParam String question) { return chatClient.prompt() .user(question) .stream() .content(); } That returns a Flux<String> you can wire straight into Server-Sent Events. We use the same pattern (SSE with polling fallback) for Job Fit Check. It's not glamorous engineering, but users notice the difference between staring at a spinner for six seconds and watching tokens appear immediately. Perceived latency is real latency as far as users are concerned. Where I'd use this, and where I wouldn't I'd rather be specific than pretend every LLM integration decision is provider-agnostic in practice. Gemini Developer API through Spring AI 2.0. Great for prototyping, great if you're already in the Google Cloud ecosystem, genuinely cheap at the Flash tier. If I were starting a greenfield AI feature today with no existing provider commitments, I'd consider it seriously, and I'd default to 2.5 Flash over the older 2.0 models unless cost pushed me toward the smaller Flash-Lite variant. OpenRouter, what we actually run in production. The appeal isn't any single model, it's the routing. We fell back to DeepSeek during a rough week of rate limiting on our primary provider, and the failover took maybe twenty minutes to wire in because our abstraction layer didn't care which provider sat underneath it. Vertex AI directly. More control, more enterprise surface area, more complexity than most teams need. If you're not already deep in GCP infrastructure, I wouldn't start there. The lesson that keeps repeating across every AI feature I've shipped: the provider matters less than the boundary you build around it. Spring AI's ChatModel interface is a genuinely good boundary. Whether the implementation behind it is Gemini, OpenAI, or a local model barely changes your application code, and that held true across the 1.0 to 2.0 upgrade too. I bumped the BOM version and changed almost nothing else. The multimodal angle worth filing away One thing that caught my attention with Gemini specifically: native multimodal input sits in the same chat API, not bolted on separately. You can pass an image alongside a text prompt through the same ChatClient : @PostMapping("/analyze-resume-image") public String analyzeResumeImage(@RequestParam("file") MultipartFile file) throws IOException...