How to Use the Gemini API with Spring Boot AI: Full Integration Guide

Wiring Google's Gemini API into a Spring Boot app using Spring AI. I'll walk through direct API setup, Vertex AI, function calling, multimodal processing, and the config gotchas that ate my afternoon.

How to Use the Gemini API with Spring Boot AI: Full Integration Guide

A client asked me to build “an AI chatbot thing” into their Spring Boot backend last quarter. I’d never touched an AI API before. I stared at the project, opened a browser, and started Googling. Within about twenty minutes I had Gemini responding to curl requests. The part that surprised me was how little code it actually took, Spring AI does a lot of the heavy lifting. But the part nobody warned me about was the config. I spent an embarrassing amount of time on a 401 error because my env var had a trailing space.

Here’s everything I learned, in the order I wish someone had told me.

Prerequisites

You’ll need:

  • Java 21 or higher
  • Spring Boot 3.2+ project
  • Google AI Studio account (free: for the API key)
  • Maven 3.8+ or Gradle 8+
  • Basic familiarity with Spring Boot and REST APIs

That’s it. No Google Cloud account needed for the direct API approach, that’s a common misconception.

Getting Your Gemini API Key

  1. Go to Google AI Studio
  2. Sign in with your Google account
  3. Click “Create API Key”
  4. Copy it somewhere safe
# Set as environment variable (recommended)
export GEMINI_API_KEY="your-api-key-here"

# Or add to .env file
GEMINI_API_KEY=your-api-key-here

Pro tip from my pain: don’t put the key directly in application.yml and commit it. Ask me how I know.

Adding the Dependencies

Add the Spring AI Google GenAI starter to your pom.xml:

<dependencies>
    <!-- Spring Boot Starter Web -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>

    <!-- Spring AI Google GenAI -->
    <dependency>
        <groupId>org.springframework.ai</groupId>
        <artifactId>spring-ai-starter-model-google-genai</artifactId>
        <version>1.0.0-M6</version>
    </dependency>

    <!-- Lombok (Optional) -->
    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
        <optional>true</optional>
    </dependency>
</dependencies>

<repositories>
    <!-- Spring AI Milestone Repository -->
    <repository>
        <id>spring-milestones</id>
        <name>Spring Milestones</name>
        <url>https://repo.spring.io/milestone</url>
        <snapshots>
            <enabled>false</enabled>
        </snapshots>
    </repository>
</repositories>

For Gradle users:

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.springframework.ai:spring-ai-starter-model-google-genai:1.0.0-M6'
    compileOnly 'org.projectlombok:lombok'
    annotationProcessor 'org.projectlombok:lombok'
}

repositories {
    maven { url 'https://repo.spring.io/milestone' }
}

Don’t forget the milestone repository. If you skip it, Maven won’t find the Spring AI artifacts and you’ll get a confusing “dependency not found” error. (Guess how I spent one of those evenings.)

Configuring Application Properties

Create or update src/main/resources/application.yml:

spring:
  application:
    name: gemini-ai-app
  ai:
    google:
      genai:
        api-key: ${GEMINI_API_KEY}
        chat:
          options:
            model: gemini-2.0-flash
            temperature: 0.7
            max-output-tokens: 8192

Or using application.properties:

# Application Name
spring.application.name=gemini-ai-app

# Google GenAI Configuration
spring.ai.google.genai.api-key=${GEMINI_API_KEY}
spring.ai.google.genai.chat.options.model=gemini-2.0-flash
spring.ai.google.genai.chat.options.temperature=0.7
spring.ai.google.genai.chat.options.max-output-tokens=8192

If you see a 401 Unauthorized and you’re sure your key is right, check for extra whitespace in the env var. That was my entire afternoon.

Building the AI Service

I like wrapping the AI logic in its own service class, keeps the controller thin and makes testing easier.

package com.example.gemini.service;

import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Flux;

@Service
public class GeminiAiService {

    private final ChatModel chatModel;
    private final ChatClient chatClient;

    public GeminiAiService(ChatModel chatModel) {
        this.chatModel = chatModel;
        this.chatClient = ChatClient.create(chatModel);
    }

    /**
     * Simple text generation
     */
    public String generateResponse(String userMessage) {
        return chatClient.prompt()
                .user(userMessage)
                .call()
                .content();
    }

    /**
     * Generate with system prompt
     */
    public String generateWithSystemPrompt(String userMessage, String systemPrompt) {
        return chatClient.prompt()
                .system(systemPrompt)
                .user(userMessage)
                .call()
                .content();
    }

    /**
     * Streaming response
     */
    public Flux<String> streamResponse(String userMessage) {
        return chatClient.prompt()
                .user(userMessage)
                .stream()
                .content();
    }

    /**
     * Generate with custom options
     */
    public String generateWithOptions(String userMessage, Double temperature, Integer maxTokens) {
        return chatClient.prompt()
                .user(userMessage)
                .options(options -> options
                        .temperature(temperature)
                        .maxTokens(maxTokens))
                .call()
                .content();
    }
}

The ChatClient API is really clean, I appreciate that Spring went with a fluent builder pattern instead of making you construct Prompt objects manually for every call.

Exposing It Through a REST Controller

package com.example.gemini.controller;

import com.example.gemini.service.GeminiAiService;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import reactor.core.publisher.Flux;

import java.util.Map;

@RestController
@RequestMapping("/api/ai")
@CrossOrigin(origins = "*")
public class GeminiAiController {

    private final GeminiAiService aiService;

    public GeminiAiController(GeminiAiService aiService) {
        this.aiService = aiService;
    }

    /**
     * Simple chat endpoint
     */
    @PostMapping("/chat")
    public ResponseEntity<Map<String, String>> chat(@RequestBody Map<String, String> request) {
        String message = request.get("message");
        String response = aiService.generateResponse(message);
        return ResponseEntity.ok(Map.of(
                "message", message,
                "response", response
        ));
    }

    /**
     * Chat with system prompt
     */
    @PostMapping("/chat/system")
    public ResponseEntity<Map<String, String>> chatWithSystem(@RequestBody Map<String, String> request) {
        String message = request.get("message");
        String systemPrompt = request.get("systemPrompt");
        String response = aiService.generateWithSystemPrompt(message, systemPrompt);
        return ResponseEntity.ok(Map.of(
                "message", message,
                "response", response
        ));
    }

    /**
     * Streaming chat endpoint
     */
    @PostMapping(value = "/chat/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public Flux<String> streamChat(@RequestBody Map<String, String> request) {
        String message = request.get("message");
        return aiService.streamResponse(message);
    }

    /**
     * Content generation endpoint
     */
    @PostMapping("/generate")
    public ResponseEntity<Map<String, Object>> generateContent(@RequestBody ContentRequest contentRequest) {
        long startTime = System.currentTimeMillis();
        String response = aiService.generateWithOptions(
                contentRequest.prompt(),
                contentRequest.temperature(),
                contentRequest.maxTokens()
        );
        long timeTaken = System.currentTimeMillis() - startTime;

        return ResponseEntity.ok(Map.of(
                "prompt", contentRequest.prompt(),
                "response", response,
                "timeTaken", timeTaken
        ));
    }

    /**
     * Health check endpoint
     */
    @GetMapping("/health")
    public ResponseEntity<Map<String, String>> health() {
        return ResponseEntity.ok(Map.of(
                "status", "UP",
                "service", "Gemini AI"
        ));
    }

    // Record for request body
    public record ContentRequest(
            String prompt,
            Double temperature,
            Integer maxTokens
    ) {}
}

Running and Testing It

# Set environment variable
export GEMINI_API_KEY="your-api-key-here"

# Run the application
./mvnw spring-boot:run

# Test the API
curl -X POST http://localhost:8080/api/ai/chat \
  -H "Content-Type: application/json" \
  -d '{"message": "Explain quantum computing in simple terms"}'

Sample Response:

{
  "message": "Explain quantum computing in simple terms",
  "response": "Quantum computing is a type of computing that uses quantum mechanics principles..."
}

When this first worked for me, I genuinely smiled. There’s something cool about getting a response back from an AI model through your own Spring Boot app, even if it’s just “Explain quantum computing.”

Vertex AI Integration (For Enterprise)

If you’re working in a Google Cloud environment and need compliance features, SLAs, or tighter GCP integration, Vertex AI is the way to go.

Additional Prerequisites

  • Google Cloud Platform account
  • Project with Vertex AI API enabled
  • gcloud CLI installed

Authentication

# Set your project
gcloud config set project YOUR_PROJECT_ID

# Authenticate
gcloud auth application-default login

Vertex AI Dependencies

<dependency>
    <groupId>org.springframework.ai</groupId>
    <artifactId>spring-ai-starter-model-vertex-ai-gemini</artifactId>
    <version>1.0.0-M6</version>
</dependency>

Vertex AI Configuration

spring:
  ai:
    model:
      chat: vertexai
    vertex:
      ai:
        gemini:
          project-id: ${GOOGLE_CLOUD_PROJECT_ID}
          location: us-central1
          chat:
            options:
              model: gemini-2.0-flash
              temperature: 0.7
              response-mime-type: text/plain

I’ll be honest, for most side projects and smaller apps, the direct API approach is plenty. Vertex AI shines when you need audit trails, IAM controls, or you’re already deep in the GCP ecosystem.

Function Calling (Tool Use)

This is the feature that made me realize AI APIs aren’t just “send text, get text.” You can let Gemini call your actual Java methods.

@Service
public class WeatherService {

    @Tool(description = "Get current weather for a location")
    public String getWeather(@ToolParam(description = "City name") String location) {
        // Simulated weather data
        return String.format("The weather in %s is 22°C with clear skies.", location);
    }

    @Tool(description = "Get stock price for a company")
    public String getStockPrice(@ToolParam(description = "Stock ticker symbol") String symbol) {
        // Simulated stock data
        return String.format("%s is currently trading at $150.25", symbol.toUpperCase());
    }
}

Wire the tools into your service:

@Service
public class GeminiAiService {

    private final ChatModel chatModel;
    private final WeatherService weatherService;

    public GeminiAiService(ChatModel chatModel, WeatherService weatherService) {
        this.chatModel = chatModel;
        this.weatherService = weatherService;
    }

    public String chatWithTools(String userMessage) {
        return ChatClient.create(chatModel)
                .prompt(userMessage)
                .tools(weatherService)
                .call()
                .content();
    }
}

Test it:

curl -X POST http://localhost:8080/api/ai/chat/tools \
  -H "Content-Type: application/json" \
  -d '{"message": "What is the weather in New York?"}'

The model figures out which tool to call based on the user’s question. First time I saw this work, it felt a little like magic, the AI decided on its own to invoke my Java method. (I know it’s just prompt routing under the hood, but still.)

Multimodal Processing (Images + Text)

Gemini can handle images and PDFs alongside text. This opens up some interesting use cases, image analysis, document parsing, that sort of thing.

@Service
public class MultimodalService {

    private final ChatModel chatModel;

    public MultimodalService(ChatModel chatModel) {
        this.chatModel = chatModel;
    }

    public String analyzeImage(byte[] imageData, String prompt) throws IOException {
        var userMessage = new UserMessage(
            prompt,
            List.of(new Media(MimeTypeUtils.IMAGE_PNG, imageData))
        );

        ChatResponse response = chatModel.call(new Prompt(List.of(userMessage)));
        return response.getResult().getOutput().getContent();
    }

    public String analyzePDF(byte[] pdfData, String prompt) throws IOException {
        var userMessage = new UserMessage(
            prompt,
            List.of(new Media(new MimeType("application", "pdf"), pdfData))
        );

        ChatResponse response = chatModel.call(new Prompt(List.of(userMessage)));
        return response.getResult().getOutput().getContent();
    }
}

Controller for file uploads:

@PostMapping(value = "/analyze/image", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ResponseEntity<Map<String, String>> analyzeImage(
        @RequestParam("file") MultipartFile file,
        @RequestParam("prompt") String prompt) throws IOException {

    String analysis = multimodalService.analyzeImage(file.getBytes(), prompt);
    return ResponseEntity.ok(Map.of(
            "analysis", analysis
    ));
}

Structured Output (JSON Mode)

Sometimes you don’t want free-form text, you want structured data you can actually parse. Gemini supports JSON mode:

@Service
public class StructuredOutputService {

    private final ChatModel chatModel;

    public StructuredOutputService(ChatModel chatModel) {
        this.chatModel = chatModel;
    }

    public String extractEntities(String text) {
        String prompt = """
            Extract entities from the following text and return as JSON:
            - persons: array of names
            - organizations: array of company/org names
            - locations: array of places
            - dates: array of dates mentioned

            Text: %s
            """.formatted(text);

        return ChatClient.create(chatModel)
                .prompt(prompt)
                .options(options -> options
                        .responseMimeType("application/json"))
                .call()
                .content();
    }
}

Example output:

{
  "persons": ["John Smith", "Jane Doe"],
  "organizations": ["Google", "Microsoft"],
  "locations": ["San Francisco", "New York"],
  "dates": ["March 2026", "Q1 2025"]
}

Conversation History (Chat Memory)

If you want multi-turn conversations, you’ll need to manage message history yourself. Spring AI gives you the pieces; you wire up the storage.

@Service
public class ConversationService {

    private final ChatModel chatModel;

    public ConversationService(ChatModel chatModel) {
        this.chatModel = chatModel;
    }

    public String chatWithHistory(String sessionId, String userMessage) {
        // Retrieve conversation history from storage
        List<Message> history = getConversationHistory(sessionId);

        // Add current user message
        history.add(new UserMessage(userMessage));

        // Get AI response
        ChatResponse response = chatModel.call(new Prompt(history));
        String aiResponse = response.getResult().getOutput().getContent();

        // Save AI response to history
        history.add(new AssistantMessage(aiResponse));
        saveConversationHistory(sessionId, history);

        return aiResponse;
    }

    private List<Message> getConversationHistory(String sessionId) {
        // Implement based on your storage (Redis, Database, etc.)
        return new ArrayList<>();
    }

    private void saveConversationHistory(String sessionId, List<Message> history) {
        // Implement storage logic
    }
}

I used Redis for this in my project. Worked fine. Just be mindful of token limits, if you keep every message forever, you’ll eventually blow past the model’s context window and get truncated responses (or errors).

Configuration Options Reference

Model Choices

ModelDescriptionBest For
gemini-2.0-flashFast, efficient multimodal modelGeneral tasks, speed-critical apps
gemini-2.0-flash-liteMost cost-effectiveHigh-volume, simple tasks
gemini-2.0-proMost capable modelComplex reasoning, advanced tasks
gemini-1.5-flashPrevious gen fast modelLegacy compatibility

Chat Configuration Properties

spring:
  ai:
    google:
      genai:
        chat:
          options:
            model: gemini-2.0-flash              # Model to use
            temperature: 0.7                      # Creativity (0.0-1.0)
            max-output-tokens: 8192              # Max tokens in response
            top-k: 40                             # Sampling diversity
            top-p: 0.95                           # Cumulative probability
            candidate-count: 1                    # Number of responses
            response-mime-type: text/plain        # Output format

What the Parameters Actually Do

Temperature (0.0 - 1.0): I think of this as the “how weird do you want the answers” knob.

  • 0.0-0.3: Factual, deterministic responses
  • 0.4-0.7: Balanced creativity and accuracy (this is where I usually live)
  • 0.8-1.0: Highly creative, potentially unpredictable

Max Output Tokens: caps the response length. 1024 for short answers, 8192+ for long-form content.

Top-K and Top-P: control sampling diversity. Lower values = more focused. Higher values = more diverse. I’ll be honest, I rarely touch these.

Error Handling and Resilience

This is the part you’ll thank yourself for adding before something goes wrong in production.

@Service
public class ResilientAiService {

    private final ChatModel chatModel;

    public ResilientAiService(ChatModel chatModel) {
        this.chatModel = chatModel;
    }

    @Retryable(
        retryFor = ApiException.class,
        maxAttempts = 3,
        backoff = @Backoff(delay = 1000)
    )
    public String generateWithRetry(String prompt) {
        try {
            return aiService.generateResponse(prompt);
        } catch (Exception e) {
            log.error("AI generation failed", e);
            throw new ApiException("Failed to generate response", e);
        }
    }

    @CircuitBreaker(name = "gemini", fallbackMethod = "fallbackResponse")
    public String generateWithCircuitBreaker(String prompt) {
        return aiService.generateResponse(prompt);
    }

    public String fallbackResponse(String prompt, Exception e) {
        return "AI service temporarily unavailable. Please try again later.";
    }
}

I got burned by this early on. Gemini’s API rate-limits you, and when it does, the default 429 response will bubble up as an unhandled exception if you haven’t set up retries. Not a great look in front of a client demo.

Rate Limiting

@RestController
@RequestMapping("/api/ai")
public class RateLimitedController {

    private final GeminiAiService aiService;

    @RateLimiter(name = "gemini", fallbackMethod = "rateLimitFallback")
    @PostMapping("/chat")
    public ResponseEntity<Map<String, String>> chat(@RequestBody Map<String, String> request) {
        // Implementation
    }

    public ResponseEntity<Map<String, String>> rateLimitFallback(Map<String, String> request) {
        return ResponseEntity
            .status(429)
            .body(Map.of("error", "Rate limit exceeded"));
    }
}

Logging and Monitoring

@Configuration
public class ObservabilityConfig {

    @Bean
    public ObservationRegistry observationRegistry() {
        return ObservationRegistry.create();
    }

    @Bean
    public AiObservationConvention aiObservationConvention() {
        return new DefaultAiObservationConvention();
    }
}

// Enable in application.properties
management.endpoints.web.exposure.include=health,metrics,prometheus
management.metrics.enable.spring.ai=true

Practical Use Cases

Customer Support Chatbot

@Service
public class SupportBotService {

    private static final String SYSTEM_PROMPT = """
        You are a helpful customer support assistant for TechCorp.
        - Be polite and professional
        - Provide accurate information
        - Escalate complex issues to human agents
        - Use knowledge base when available
        """;

    public String handleSupportQuery(String query) {
        return aiService.generateWithSystemPrompt(query, SYSTEM_PROMPT);
    }
}

Content Generation API

@PostMapping("/generate/blog-post")
public ResponseEntity<BlogPostResponse> generateBlogPost(@RequestBody BlogPostRequest request) {
    String prompt = """
        Write a blog post about %s.
        Target audience: %s
        Tone: %s
        Length: approximately %d words
        Include: introduction, main points, conclusion
        """.formatted(
            request.topic(),
            request.audience(),
            request.tone(),
            request.wordCount()
        );

    String content = aiService.generateWithOptions(prompt, 0.7, 4096);

    return ResponseEntity.ok(new BlogPostResponse(content));
}

Code Review Assistant

@Service
public class CodeReviewService {

    private static final String CODE_REVIEW_PROMPT = """
        Review the following code for:
        1. Bugs and potential issues
        2. Code quality and best practices
        3. Performance optimizations
        4. Security vulnerabilities

        Provide specific, actionable feedback.

        Code:
        %s
        """;

    public String reviewCode(String code) {
        return aiService.generateResponse(CODE_REVIEW_PROMPT.formatted(code));
    }
}

Data Extraction Pipeline

@Service
public class DataExtractionService {

    public Map<String, Object> extractStructuredData(String text, String schema) {
        String prompt = """
            Extract data from the text following this JSON schema:
            %s

            Text:
            %s

            Return only valid JSON.
            """.formatted(schema, text);

        String jsonResult = ChatClient.create(chatModel)
                .prompt(prompt)
                .options(opts -> opts.responseMimeType("application/json"))
                .call()
                .content();

        return objectMapper.readValue(jsonResult, Map.class);
    }
}

Troubleshooting

401 Unauthorized: Invalid API Key

This is almost always one of three things: the env var isn’t set, the key has a trailing space, or you committed the wrong key. Double-check with echo $GEMINI_API_KEY and look at the output carefully.

429 Too Many Requests

You’ve hit the rate limit. Add exponential backoff (see the Resilience4j section above) and maybe lower your request volume. Gemini’s free tier is generous but not infinite.

404 Model Not Found

Check the model name string. It’s case-sensitive and the available models change. Verify against the Gemini docs.

Timeout Errors

If responses are timing out, bump the timeouts:

spring:
  ai:
    google:
      genai:
        connection-timeout: 30s
        read-timeout: 60s

Deployment

Docker

FROM eclipse-temurin:21-jdk-alpine

WORKDIR /app

COPY target/gemini-app.jar app.jar

ENV GEMINI_API_KEY=""

EXPOSE 8080

ENTRYPOINT ["java", "-jar", "app.jar"]

Kubernetes Secrets

apiVersion: v1
kind: Secret
metadata:
  name: gemini-api-key
type: Opaque
stringData:
  GEMINI_API_KEY: your-api-key-here
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: gemini-app
spec:
  template:
    spec:
      containers:
      - name: gemini
        image: gemini-app:latest
        env:
        - name: GEMINI_API_KEY
          valueFrom:
            secretKeyRef:
              name: gemini-api-key
              key: GEMINI_API_KEY

I’d strongly recommend using Kubernetes Secrets (or a vault) rather than environment variables baked into your container image. The API key is the one thing you don’t want in a git log.

Cost Tracking

Gemini’s pricing is usage-based, so keep an eye on your token consumption:

@Service
public class UsageTrackingService {

    private final AtomicInteger tokenCounter = new AtomicInteger(0);

    public String trackUsage(String prompt, String response) {
        int inputTokens = estimateTokens(prompt);
        int outputTokens = estimateTokens(response);

        tokenCounter.addAndGet(inputTokens + outputTokens);

        log.info("Total tokens used: {}", tokenCounter.get());

        return response;
    }

    private int estimateTokens(String text) {
        // Rough estimate: 1 token ≈ 4 characters
        return text.length() / 4;
    }
}

I forgot to add this early on and was surprised by the bill at the end of the month. Not a huge amount, but enough to make me add tracking.

Testing

Unit Tests

@SpringBootTest
class GeminiAiServiceTest {

    @Autowired
    private GeminiAiService aiService;

    @Test
    void shouldGenerateResponse() {
        String response = aiService.generateResponse("Say hello");

        assertThat(response).isNotNull();
        assertThat(response).isNotBlank();
    }

    @Test
    void shouldHandleSystemPrompt() {
        String systemPrompt = "You are a helpful assistant.";
        String response = aiService.generateWithSystemPrompt(
            "What is 2+2?",
            systemPrompt
        );

        assertThat(response).contains("4");
    }
}

Integration Tests

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureMockMvc
class GeminiAiControllerIntegrationTest {

    @Autowired
    private MockMvc mockMvc;

    @Test
    void shouldReturnChatResponse() throws Exception {
        String requestBody = "{\"message\": \"Hello\"}";

        mockMvc.perform(post("/api/ai/chat")
                .contentType(MediaType.APPLICATION_JSON)
                .content(requestBody))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.response").exists());
    }
}

Resources

LibraryPurpose
resilience4jCircuit breaker, retry
micrometerMetrics and monitoring
spring-boot-starter-validationInput validation
springdoc-openapiAPI documentation

What I Took Away

The thing that surprised me most about integrating Gemini into Spring Boot was how boring the plumbing was. Spring AI really does handle the hard parts, authentication, request formatting, streaming, even tool invocation. The actual work is mostly config, error handling, and deciding which model to use.

If you’re starting from zero with AI APIs in Java, I’d say: get the basic chat endpoint working first. Then add the resilience stuff. Then worry about streaming and tools. Trying to do everything at once is how you end up debugging three things at the same time (which is exactly what I did).

One last thing: the free tier is surprisingly generous. I prototyped the entire chatbot feature without spending a cent, then moved to paid only when we went to production. Start there.

Member discussion

0 comments

Start the conversation

Become a member of >hacksubset_ to start commenting.