Build an AI Operations Assistant

Use Spring AI to build a REST API that queries backend data via natural language.

Introduction

30 Second Summary

Every backend team has the same problem. Someone needs data from the system, and the answer is always "write a new endpoint" or "go check the database." What if users could just ask questions in plain English and get real answers from your backend?

In this project, you will build an AI-powered operations assistant API that lets users query e-commerce data using natural language. The LLM calls your backend tools automatically, retrieves real data from a database, and remembers prior conversation context.

What You'll Build

A Spring Boot 4.x REST API that uses Spring AI 2.0.0 to connect an LLM to your backend data layer through tool calling, structured output, and multi-turn chat memory.

By the end of this project, you'll have:

  • A working /api/chat endpoint where users ask natural language questions and receive real answers from an H2 database.
  • Three @Tool-annotated classes that let the LLM invoke your backend methods for orders, products, and customers.
  • A structured output endpoint that parses natural language queries into typed Java records, then executes them against the database.
  • Multi-turn chat memory so the assistant remembers context across messages in a conversation.
  • Secret Mission: Add a streaming response endpoint using Server-Sent Events so tokens arrive incrementally.

Are there any prerequisites?

This project assumes familiarity with Spring Boot fundamentals like controllers, services, repositories, and dependency injection. You should also have a basic understanding of REST APIs.

You do not need any prior experience with AI or LLMs. The project walks you through everything from setting up your free API key to wiring tool calling into your application.

Set Up the Spring Boot Project and Google AI Studio

Your AI-powered operations assistant needs a backbone to run on. Spring Boot provides the web framework, and Spring AI connects it to Google's Gemini model.

In this step, you will generate a fresh Spring Boot project, wire in the Spring AI dependency, obtain a free API key from Google AI Studio, and confirm everything works with a test endpoint.

In this step, get ready to:
  • Generate a Spring Boot 4.x project and add the Spring AI dependency.
  • Get a free Google AI Studio API key for the Gemini model.
  • Verify the integration by hitting a test endpoint that returns a live Gemini response.
Generate the Spring Boot project and add Spring AI

Spring Initializr is a web tool that generates pre-configured Spring Boot projects. You will use it to scaffold your project, then manually add the Spring AI dependency.

  • Open your browser and navigate to start.spring.io.
  • Set **Project** to Maven.
  • Set Language to Java.
  • Set Spring Boot to 4.0.0 (or the latest 4.x stable release).
  • Under Project Metadata, leave Group as com.example and Artifact as demo.
  • Set Java version to 25.
  • Click Add Dependencies and add: Spring Web, Spring Data JPA, H2 Database, and Spring Boot Actuator.
  • Click Generate to download the zip file.
  • Extract the zip to your Desktop.
  • Open the extracted demo folder in your IDE.

What is Spring Initializr?

Spring Initializr is the official project generator for Spring Boot. It creates a ready-to-run project skeleton with your chosen dependencies, build tool, and Java version already configured.

The generated project includes the core dependencies, but not Spring AI. You need to add the Spring AI Bill of Materials (BOM) and the Google GenAI starter manually.

  • Open pom.xml in your IDE.
  • Find the <dependencies> section and add the Spring AI starter after the existing dependencies:
<dependency>
    <groupId>org.springframework.ai</groupId>
    <artifactId>spring-ai-starter-model-google-genai</artifactId>
</dependency>

Why no version number on the dependency?

The Spring AI BOM (which you will add next) manages all Spring AI artifact versions for you. This keeps every Spring AI library in sync without manually specifying version numbers on each one.

  • Now add a <dependencyManagement> section below the <dependencies> block. Paste the following:
<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>

What does this code do?

The spring-ai-bom is a Bill of Materials. It locks every Spring AI artifact in your project to version 2.0.0, so you never get mismatched library versions.

The spring-ai-starter-model-google-genai starter auto-configures a connection to Google's Gemini models. Spring Boot will wire everything up as long as you provide an API key.

  • Save pom.xml.
  • Verify the project compiles by running this command in your terminal from the project root (the demo folder):

macOS

./mvnw clean compile

Windows

mvnw.cmd clean compile
  • You should see BUILD SUCCESS at the end of the output.

Seeing BUILD FAILURE?

  • Make sure the <dependencyManagement> block is a sibling of <dependencies>, not nested inside it.
  • Check that the spring-ai-bom version is exactly 2.0.0 and that <type>pom</type> and <scope>import</scope> are both present.
  • Confirm you have internet access. Maven needs to download the Spring AI artifacts from Maven Central.

Ask for help: My Maven build is failing after adding the Spring AI BOM. What should I check?

Get your Google AI Studio API key

Google AI Studio provides free access to the Gemini API. No billing setup is required.

  • Open https://aistudio.google.com/apikey in your browser.
  • Sign in with your Google account if prompted.
  • Click Create API Key.
  • Copy the generated key. You will only see it once, so keep it somewhere safe.

Is this free?

Yes. Google AI Studio offers a free tier for the Gemini API with no credit card required. The free tier provides enough capacity for development and testing.

Now configure your application to use the key and the Gemini model.

  • Open src/main/resources/application.properties in your IDE.
  • Replace its contents with the following configuration:
# Google Gemini API configuration
spring.ai.google.genai.api-key=your-key-here
spring.ai.google.genai.chat.model=gemini-2.5-flash

# H2 in-memory database console (used in later steps)
spring.h2.console.enabled=true
spring.datasource.url=jdbc:h2:mem:testdb
  • Replace your-key-here with the API key you copied from Google AI Studio.
  • Save the file.

What does this code do?

  • spring.ai.google.genai.api-key tells Spring AI how to authenticate with Google's API.
  • spring.ai.google.genai.chat.model=gemini-2.5-flash selects the Gemini 2.5 Flash model, which is fast and has generous free-tier rate limits.
  • spring.h2.console.enabled=true enables the H2 database web console, which you will use to verify data in later steps.
  • spring.datasource.url=jdbc:h2:mem:testdb creates an in-memory database called testdb.

Worried about exposing your API key?

Never commit application.properties with a real key to a public repository. For this local project it is fine, but for production use environment variables or a secrets manager.

Ask for help: How do I keep my API key out of version control in a Spring Boot project?

Create a test controller to verify the integration

The fastest way to confirm Spring AI is wired correctly is to create a simple REST endpoint that sends a prompt to Gemini and returns whatever it says.

  • In your IDE, create a new Java file at src/main/java/com/example/demo/TestController.java.
  • Paste the following code:
package com.example.demo;

import org.springframework.ai.chat.client.ChatClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class TestController {

    // Spring AI auto-configures a ChatClient.Builder bean
    private final ChatClient chatClient;

    public TestController(ChatClient.Builder builder) {
        this.chatClient = builder.build();
    }

    // Simple endpoint to verify Gemini connectivity
    @GetMapping("/test")
    public String test() {
        return chatClient.prompt("Say hello from Gemini").call().content();
    }
}

What does this code do?

  • Spring Boot auto-configures a ChatClient.Builder bean and injects it into the constructor. You call builder.build() to create a ready-to-use ChatClient.
  • The /test endpoint sends the prompt "Say hello from Gemini" to the model and returns its response as plain text.
  • chatClient.prompt(...).call().content() is the fluent API chain: build a prompt, call the model, extract the text content from the response.
  • Save TestController.java.
  • Start the application by running this command from your project root:

macOS

./mvnw spring-boot:run

Windows

mvnw.cmd spring-boot:run
  • Wait for the output to show Started DemoApplication (this confirms the app is running on port 8080).
  • Open a new terminal tab and test the endpoint by running:
curl http://localhost:8080/test
  • You should see a response from Gemini (something like "Hello from Gemini!" or a similar greeting).

Not seeing a response?

  • Check the terminal running your app for error messages. A common issue is an invalid API key. Verify the key in application.properties matches what you copied from Google AI Studio.
  • If you see Connection refused, make sure the application started successfully before running the curl command.
  • If you see a 401 or 403 error, your API key may be incorrect or your Google account may not have the Gemini API enabled.

Ask for help: My /test endpoint is not returning a Gemini response. How do I debug this?

  • Stop the running application with Ctrl+C in the terminal.

✔️ Awesome, I've got everything!

Great. Double check that you saved all three files before moving on.

ⓧ I'd like to double check the full code

Here is the full state of each file at the end of this step.

<project>
  <!-- Spring Boot 4.x parent -->
  <parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>4.0.0</version>
  </parent>
  <dependencies>
    <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId></dependency>
    <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-data-jpa</artifactId></dependency>
    <dependency><groupId>com.h2database</groupId><artifactId>h2</artifactId><scope>runtime</scope></dependency>
    <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-actuator</artifactId></dependency>
    <dependency><groupId>org.springframework.ai</groupId><artifactId>spring-ai-starter-model-google-genai</artifactId></dependency>
  </dependencies>
  <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>
</project>
package com.example.demo;

import org.springframework.ai.chat.client.ChatClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class TestController {

    private final ChatClient chatClient;

    public TestController(ChatClient.Builder builder) {
        this.chatClient = builder.build();
    }

    @GetMapping("/test")
    public String test() {
        return chatClient.prompt("Say hello from Gemini").call().content();
    }
}
spring.ai.google.genai.api-key=your-key-here
spring.ai.google.genai.chat.model=gemini-2.5-flash
spring.h2.console.enabled=true
spring.datasource.url=jdbc:h2:mem:testdb

Your Spring AI integration is live. Next up, you will build the domain layer with JPA entities and seed the H2 database with sample e-commerce data.

Build the Domain Layer and Seed Data

In the previous step, you confirmed that Spring AI can talk to Gemini. Now you need data for the AI to query.

Your operations assistant needs a domain model that represents the e-commerce data it will search through. In this step, you will create JPA entities for customers, products, and orders, wire up repositories with custom query methods, and seed the database with sample data on startup.

In this step, get ready to:
  • Create JPA entity classes with proper relationships.
  • Define Spring Data JPA repositories with custom query methods.
  • Seed the H2 database with sample data using a CommandLineRunner.
Create the JPA entity classes

Your domain model has four entities connected by relationships. A Customer has many Orders. Each Order has many OrderItems. Each OrderItem references a Product.

  • Create a new package called entity inside src/main/java/com/example/demo/.
  • Create a new file called Customer.java inside the entity package and paste the following code:
package com.example.demo.entity;

import jakarta.persistence.*;
import java.util.List;

@Entity
public class Customer {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;
    private String email;

    // One customer can have many orders
    @OneToMany(mappedBy = "customer")
    private List<Order> orders;

    public Customer() {}

    public Customer(String name, String email) {
        this.name = name;
        this.email = email;
    }

    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }
    public String getName() { return name; }
    public void setName(String name) { this.name = name; }
    public String getEmail() { return email; }
    public void setEmail(String email) { this.email = email; }
    public List<Order> getOrders() { return orders; }
    public void setOrders(List<Order> orders) { this.orders = orders; }
}
  • Create a new file called Product.java in the same entity package and paste:
package com.example.demo.entity;

import jakarta.persistence.*;

@Entity
public class Product {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;
    private Double price;
    private Integer stockQuantity;

    public Product() {}

    public Product(String name, Double price, Integer stockQuantity) {
        this.name = name;
        this.price = price;
        this.stockQuantity = stockQuantity;
    }

    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }
    public String getName() { return name; }
    public void setName(String name) { this.name = name; }
    public Double getPrice() { return price; }
    public void setPrice(Double price) { this.price = price; }
    public Integer getStockQuantity() { return stockQuantity; }
    public void setStockQuantity(Integer stockQuantity) { this.stockQuantity = stockQuantity; }
}

What do these entities do?

Customer and Product are the two independent entities in your domain. They do not depend on each other to exist.

The @Entity annotation tells JPA to map this class to a database table. @Id and @GeneratedValue make the id field an auto-incrementing primary key.

  • Create Order.java in the entity package and paste:
package com.example.demo.entity;

import jakarta.persistence.*;
import java.time.LocalDate;
import java.util.List;

@Entity
@Table(name = "orders")
public class Order {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    // Each order belongs to one customer
    @ManyToOne
    private Customer customer;

    private LocalDate orderDate;
    private String status;
    private Double totalAmount;

    // One order has many line items
    @OneToMany(mappedBy = "order", cascade = CascadeType.ALL)
    private List<OrderItem> items;

    public Order() {}

    public Order(Customer customer, LocalDate orderDate, String status, Double totalAmount) {
        this.customer = customer;
        this.orderDate = orderDate;
        this.status = status;
        this.totalAmount = totalAmount;
    }

    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }
    public Customer getCustomer() { return customer; }
    public void setCustomer(Customer customer) { this.customer = customer; }
    public LocalDate getOrderDate() { return orderDate; }
    public void setOrderDate(LocalDate orderDate) { this.orderDate = orderDate; }
    public String getStatus() { return status; }
    public void setStatus(String status) { this.status = status; }
    public Double getTotalAmount() { return totalAmount; }
    public void setTotalAmount(Double totalAmount) { this.totalAmount = totalAmount; }
    public List<OrderItem> getItems() { return items; }
    public void setItems(List<OrderItem> items) { this.items = items; }
}
  • Create OrderItem.java in the entity package and paste:
package com.example.demo.entity;

import jakarta.persistence.*;

@Entity
public class OrderItem {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    // Each item belongs to one order
    @ManyToOne
    private Order order;

    // Each item references one product
    @ManyToOne
    private Product product;

    private Integer quantity;
    private Double unitPrice;

    public OrderItem() {}

    public OrderItem(Order order, Product product, Integer quantity, Double unitPrice) {
        this.order = order;
        this.product = product;
        this.quantity = quantity;
        this.unitPrice = unitPrice;
    }

    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }
    public Order getOrder() { return order; }
    public void setOrder(Order order) { this.order = order; }
    public Product getProduct() { return product; }
    public void setProduct(Product product) { this.product = product; }
    public Integer getQuantity() { return quantity; }
    public void setQuantity(Integer quantity) { this.quantity = quantity; }
    public Double getUnitPrice() { return unitPrice; }
    public void setUnitPrice(Double unitPrice) { this.unitPrice = unitPrice; }
}

What do Order and OrderItem do?

Order uses @Table(name = "orders") because ORDER is a reserved SQL keyword. Without the custom table name, H2 would reject the table creation.

The @ManyToOne annotation on Order.customer creates a foreign key back to the Customer table. @OneToMany(mappedBy = "order", cascade = CascadeType.ALL) on the items list means deleting an order also deletes its items.

  • Save all four entity files.

Seeing red underlines in your IDE?

If Customer.java shows an error on List<Order>, that is expected until you create Order.java. Once all four files exist, the errors resolve.

If you see an error on jakarta.persistence imports, make sure spring-boot-starter-data-jpa is in your pom.xml dependencies.

help me fix JPA import errors in my entity classes

✔️ Awesome, I've got everything!

Great. Double check you have saved all four entity files before moving on.

ⓧ I'd like to double check the full code

package com.example.demo.entity;

import jakarta.persistence.*;
import java.util.List;

@Entity
public class Customer {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;
    private String email;

    // One customer can have many orders
    @OneToMany(mappedBy = "customer")
    private List<Order> orders;

    public Customer() {}

    public Customer(String name, String email) {
        this.name = name;
        this.email = email;
    }

    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }
    public String getName() { return name; }
    public void setName(String name) { this.name = name; }
    public String getEmail() { return email; }
    public void setEmail(String email) { this.email = email; }
    public List<Order> getOrders() { return orders; }
    public void setOrders(List<Order> orders) { this.orders = orders; }
}
package com.example.demo.entity;

import jakarta.persistence.*;

@Entity
public class Product {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;
    private Double price;
    private Integer stockQuantity;

    public Product() {}

    public Product(String name, Double price, Integer stockQuantity) {
        this.name = name;
        this.price = price;
        this.stockQuantity = stockQuantity;
    }

    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }
    public String getName() { return name; }
    public void setName(String name) { this.name = name; }
    public Double getPrice() { return price; }
    public void setPrice(Double price) { this.price = price; }
    public Integer getStockQuantity() { return stockQuantity; }
    public void setStockQuantity(Integer stockQuantity) { this.stockQuantity = stockQuantity; }
}
package com.example.demo.entity;

import jakarta.persistence.*;
import java.time.LocalDate;
import java.util.List;

@Entity
@Table(name = "orders")
public class Order {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    // Each order belongs to one customer
    @ManyToOne
    private Customer customer;

    private LocalDate orderDate;
    private String status;
    private Double totalAmount;

    // One order has many line items
    @OneToMany(mappedBy = "order", cascade = CascadeType.ALL)
    private List<OrderItem> items;

    public Order() {}

    public Order(Customer customer, LocalDate orderDate, String status, Double totalAmount) {
        this.customer = customer;
        this.orderDate = orderDate;
        this.status = status;
        this.totalAmount = totalAmount;
    }

    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }
    public Customer getCustomer() { return customer; }
    public void setCustomer(Customer customer) { this.customer = customer; }
    public LocalDate getOrderDate() { return orderDate; }
    public void setOrderDate(LocalDate orderDate) { this.orderDate = orderDate; }
    public String getStatus() { return status; }
    public void setStatus(String status) { this.status = status; }
    public Double getTotalAmount() { return totalAmount; }
    public void setTotalAmount(Double totalAmount) { this.totalAmount = totalAmount; }
    public List<OrderItem> getItems() { return items; }
    public void setItems(List<OrderItem> items) { this.items = items; }
}
package com.example.demo.entity;

import jakarta.persistence.*;

@Entity
public class OrderItem {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    // Each item belongs to one order
    @ManyToOne
    private Order order;

    // Each item references one product
    @ManyToOne
    private Product product;

    private Integer quantity;
    private Double unitPrice;

    public OrderItem() {}

    public OrderItem(Order order, Product product, Integer quantity, Double unitPrice) {
        this.order = order;
        this.product = product;
        this.quantity = quantity;
        this.unitPrice = unitPrice;
    }

    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }
    public Order getOrder() { return order; }
    public void setOrder(Order order) { this.order = order; }
    public Product getProduct() { return product; }
    public void setProduct(Product product) { this.product = product; }
    public Integer getQuantity() { return quantity; }
    public void setQuantity(Integer quantity) { this.quantity = quantity; }
    public Double getUnitPrice() { return unitPrice; }
    public void setUnitPrice(Double unitPrice) { this.unitPrice = unitPrice; }
}
Create Spring Data JPA repositories

Spring Data JPA generates database queries automatically from method names. You define an interface, and Spring implements the SQL behind the scenes.

  • Create a new package called repository inside src/main/java/com/example/demo/.
  • Create CustomerRepository.java in the repository package and paste:
package com.example.demo.repository;

import com.example.demo.entity.Customer;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;

public interface CustomerRepository extends JpaRepository<Customer, Long> {
    // Spring Data generates the SQL: SELECT * FROM customer WHERE LOWER(name) LIKE LOWER('%name%')
    List<Customer> findByNameContainingIgnoreCase(String name);
}
  • Create ProductRepository.java in the same package and paste:
package com.example.demo.repository;

import com.example.demo.entity.Product;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;

public interface ProductRepository extends JpaRepository<Product, Long> {
    // Find products with stock below a threshold
    List<Product> findByStockQuantityLessThan(int threshold);
    // Search products by keyword in name
    List<Product> findByNameContainingIgnoreCase(String keyword);
}
  • Create OrderRepository.java and paste:
package com.example.demo.repository;

import com.example.demo.entity.Order;
import org.springframework.data.jpa.repository.JpaRepository;
import java.time.LocalDate;
import java.util.List;

public interface OrderRepository extends JpaRepository<Order, Long> {
    // Find orders by customer name (case-insensitive partial match)
    List<Order> findByCustomerNameContainingIgnoreCase(String customerName);
    // Find orders between two dates
    List<Order> findByOrderDateBetween(LocalDate from, LocalDate to);
    // Find orders by status (e.g., PENDING, SHIPPED, COMPLETED)
    List<Order> findByStatus(String status);
}
  • Create OrderItemRepository.java and paste:
package com.example.demo.repository;

import com.example.demo.entity.OrderItem;
import org.springframework.data.jpa.repository.JpaRepository;

public interface OrderItemRepository extends JpaRepository<OrderItem, Long> {
}

How do query methods work?

Spring Data parses method names into SQL queries at startup. For example, findByCustomerNameContainingIgnoreCase becomes a query that searches the customer.name field with a case-insensitive LIKE clause.

You never write the SQL yourself. The LLM will call these repository methods through tool functions in the next step.

  • Save all four repository files.

Seeing unresolved import errors?

Make sure the import paths match your entity package exactly: com.example.demo.entity.Customer, com.example.demo.entity.Product, etc.

help me fix unresolved imports in my Spring Data repository interfaces

✔️ Awesome, I've got everything!

Great. Double check you have saved all four repository files before moving on.

ⓧ I'd like to double check the full code

package com.example.demo.repository;

import com.example.demo.entity.Customer;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;

public interface CustomerRepository extends JpaRepository<Customer, Long> {
    List<Customer> findByNameContainingIgnoreCase(String name);
}
package com.example.demo.repository;

import com.example.demo.entity.Product;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;

public interface ProductRepository extends JpaRepository<Product, Long> {
    List<Product> findByStockQuantityLessThan(int threshold);
    List<Product> findByNameContainingIgnoreCase(String keyword);
}
package com.example.demo.repository;

import com.example.demo.entity.Order;
import org.springframework.data.jpa.repository.JpaRepository;
import java.time.LocalDate;
import java.util.List;

public interface OrderRepository extends JpaRepository<Order, Long> {
    List<Order> findByCustomerNameContainingIgnoreCase(String customerName);
    List<Order> findByOrderDateBetween(LocalDate from, LocalDate to);
    List<Order> findByStatus(String status);
}
package com.example.demo.repository;

import com.example.demo.entity.OrderItem;
import org.springframework.data.jpa.repository.JpaRepository;

public interface OrderItemRepository extends JpaRepository<OrderItem, Long> {
}
Create the DataInitializer to seed the database

An empty database is not useful for testing your AI assistant. You need sample data that the LLM's tools can query against.

  • Create a new file called DataInitializer.java directly inside src/main/java/com/example/demo/ (not in the entity or repository package) and paste:
package com.example.demo;

import com.example.demo.entity.*;
import com.example.demo.repository.*;
import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Component;
import java.time.LocalDate;

@Component
public class DataInitializer implements CommandLineRunner {

    private final CustomerRepository customerRepository;
    private final ProductRepository productRepository;
    private final OrderRepository orderRepository;
    private final OrderItemRepository orderItemRepository;

    public DataInitializer(CustomerRepository customerRepository,
                           ProductRepository productRepository,
                           OrderRepository orderRepository,
                           OrderItemRepository orderItemRepository) {
        this.customerRepository = customerRepository;
        this.productRepository = productRepository;
        this.orderRepository = orderRepository;
        this.orderItemRepository = orderItemRepository;
    }

    @Override
    public void run(String... args) {
        // Create 5 customers
        Customer alice = customerRepository.save(new Customer("Alice Johnson", "alice@example.com"));
        Customer bob = customerRepository.save(new Customer("Bob Smith", "bob@example.com"));
        Customer carol = customerRepository.save(new Customer("Carol Davis", "carol@example.com"));
        Customer dave = customerRepository.save(new Customer("Dave Wilson", "dave@example.com"));
        Customer eve = customerRepository.save(new Customer("Eve Martinez", "eve@example.com"));

        // Create 10 products
        Product laptop = productRepository.save(new Product("Laptop Pro 15", 1299.99, 25));
        Product mouse = productRepository.save(new Product("Wireless Mouse", 29.99, 150));
        Product keyboard = productRepository.save(new Product("Mechanical Keyboard", 89.99, 75));
        Product monitor = productRepository.save(new Product("4K Monitor", 499.99, 30));
        Product headset = productRepository.save(new Product("Noise-Canceling Headset", 199.99, 60));
        Product webcam = productRepository.save(new Product("HD Webcam", 79.99, 3));
        Product usbHub = productRepository.save(new Product("USB-C Hub", 49.99, 100));
        Product ssd = productRepository.save(new Product("1TB SSD", 109.99, 45));
        Product charger = productRepository.save(new Product("Fast Charger", 34.99, 200));
        Product stand = productRepository.save(new Product("Laptop Stand", 44.99, 2));

        // Create 15 orders with items
        createOrder(alice, LocalDate.now().minusDays(2), "COMPLETED", laptop, 1, mouse, 2);
        createOrder(alice, LocalDate.now().minusDays(10), "COMPLETED", keyboard, 1, headset, 1);
        createOrder(bob, LocalDate.now().minusDays(1), "PENDING", monitor, 1, usbHub, 1);
        createOrder(bob, LocalDate.now().minusDays(5), "SHIPPED", ssd, 2, charger, 1);
        createOrder(bob, LocalDate.now().minusDays(15), "COMPLETED", mouse, 3, keyboard, 1);
        createOrder(carol, LocalDate.now().minusDays(3), "PENDING", laptop, 1, stand, 1);
        createOrder(carol, LocalDate.now().minusDays(7), "COMPLETED", webcam, 2, usbHub, 1);
        createOrder(carol, LocalDate.now().minusDays(20), "COMPLETED", headset, 1, charger, 2);
        createOrder(dave, LocalDate.now().minusDays(1), "PENDING", monitor, 2, keyboard, 2);
        createOrder(dave, LocalDate.now().minusDays(4), "SHIPPED", laptop, 1, mouse, 1);
        createOrder(dave, LocalDate.now().minusDays(12), "COMPLETED", ssd, 1, stand, 1);
        createOrder(eve, LocalDate.now().minusDays(2), "PENDING", webcam, 1, headset, 1);
        createOrder(eve, LocalDate.now().minusDays(6), "SHIPPED", charger, 3, usbHub, 2);
        createOrder(eve, LocalDate.now().minusDays(9), "COMPLETED", laptop, 1, keyboard, 1);
        createOrder(eve, LocalDate.now().minusDays(14), "COMPLETED", mouse, 5, stand, 2);
    }

    private void createOrder(Customer customer, LocalDate date, String status,
                             Product product1, int qty1, Product product2, int qty2) {
        // Calculate total from product prices and quantities
        double total = (product1.getPrice() * qty1) + (product2.getPrice() * qty2);
        Order order = orderRepository.save(new Order(customer, date, status, total));
        orderItemRepository.save(new OrderItem(order, product1, qty1, product1.getPrice()));
        orderItemRepository.save(new OrderItem(order, product2, qty2, product2.getPrice()));
    }
}

What does DataInitializer do?

The @Component annotation registers this class with Spring. Because it implements CommandLineRunner, Spring automatically calls its run() method after the application starts.

The helper method createOrder() calculates the order total from product prices and creates two OrderItem rows per order. This gives you 5 customers, 10 products, and 15 orders with 30 line items total.

  • Save DataInitializer.java.
  • Run the application by executing this command in your terminal:
mvnw.cmd spring-boot:run
  • Open your browser and navigate to http://localhost:8080/h2-console.
  • In the JDBC URL field, enter jdbc:h2:mem:testdb.
  • Leave the User Name as sa and the Password field empty.
  • Click Connect.
  • Run SELECT * FROM CUSTOMER in the SQL editor. You should see 5 rows of customer data.

H2 console not loading?

Make sure your application is still running. The H2 console is only accessible while the Spring Boot app is active.

If you see a connection error, verify the JDBC URL matches jdbc:h2:mem:testdb exactly. This must match the spring.datasource.url in your application.properties.

help me connect to the H2 console in my Spring Boot application

✔️ Awesome, I've got everything!

Great. You have confirmed the database is seeded and ready for your AI tools.

ⓧ I'd like to double check the full code

package com.example.demo;

import com.example.demo.entity.*;
import com.example.demo.repository.*;
import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Component;
import java.time.LocalDate;

@Component
public class DataInitializer implements CommandLineRunner {

    private final CustomerRepository customerRepository;
    private final ProductRepository productRepository;
    private final OrderRepository orderRepository;
    private final OrderItemRepository orderItemRepository;

    public DataInitializer(CustomerRepository customerRepository,
                           ProductRepository productRepository,
                           OrderRepository orderRepository,
                           OrderItemRepository orderItemRepository) {
        this.customerRepository = customerRepository;
        this.productRepository = productRepository;
        this.orderRepository = orderRepository;
        this.orderItemRepository = orderItemRepository;
    }

    @Override
    public void run(String... args) {
        // Create 5 customers
        Customer alice = customerRepository.save(new Customer("Alice Johnson", "alice@example.com"));
        Customer bob = customerRepository.save(new Customer("Bob Smith", "bob@example.com"));
        Customer carol = customerRepository.save(new Customer("Carol Davis", "carol@example.com"));
        Customer dave = customerRepository.save(new Customer("Dave Wilson", "dave@example.com"));
        Customer eve = customerRepository.save(new Customer("Eve Martinez", "eve@example.com"));

        // Create 10 products
        Product laptop = productRepository.save(new Product("Laptop Pro 15", 1299.99, 25));
        Product mouse = productRepository.save(new Product("Wireless Mouse", 29.99, 150));
        Product keyboard = productRepository.save(new Product("Mechanical Keyboard", 89.99, 75));
        Product monitor = productRepository.save(new Product("4K Monitor", 499.99, 30));
        Product headset = productRepository.save(new Product("Noise-Canceling Headset", 199.99, 60));
        Product webcam = productRepository.save(new Product("HD Webcam", 79.99, 3));
        Product usbHub = productRepository.save(new Product("USB-C Hub", 49.99, 100));
        Product ssd = productRepository.save(new Product("1TB SSD", 109.99, 45));
        Product charger = productRepository.save(new Product("Fast Charger", 34.99, 200));
        Product stand = productRepository.save(new Product("Laptop Stand", 44.99, 2));

        // Create 15 orders with items
        createOrder(alice, LocalDate.now().minusDays(2), "COMPLETED", laptop, 1, mouse, 2);
        createOrder(alice, LocalDate.now().minusDays(10), "COMPLETED", keyboard, 1, headset, 1);
        createOrder(bob, LocalDate.now().minusDays(1), "PENDING", monitor, 1, usbHub, 1);
        createOrder(bob, LocalDate.now().minusDays(5), "SHIPPED", ssd, 2, charger, 1);
        createOrder(bob, LocalDate.now().minusDays(15), "COMPLETED", mouse, 3, keyboard, 1);
        createOrder(carol, LocalDate.now().minusDays(3), "PENDING", laptop, 1, stand, 1);
        createOrder(carol, LocalDate.now().minusDays(7), "COMPLETED", webcam, 2, usbHub, 1);
        createOrder(carol, LocalDate.now().minusDays(20), "COMPLETED", headset, 1, charger, 2);
        createOrder(dave, LocalDate.now().minusDays(1), "PENDING", monitor, 2, keyboard, 2);
        createOrder(dave, LocalDate.now().minusDays(4), "SHIPPED", laptop, 1, mouse, 1);
        createOrder(dave, LocalDate.now().minusDays(12), "COMPLETED", ssd, 1, stand, 1);
        createOrder(eve, LocalDate.now().minusDays(2), "PENDING", webcam, 1, headset, 1);
        createOrder(eve, LocalDate.now().minusDays(6), "SHIPPED", charger, 3, usbHub, 2);
        createOrder(eve, LocalDate.now().minusDays(9), "COMPLETED", laptop, 1, keyboard, 1);
        createOrder(eve, LocalDate.now().minusDays(14), "COMPLETED", mouse, 5, stand, 2);
    }

    private void createOrder(Customer customer, LocalDate date, String status,
                             Product product1, int qty1, Product product2, int qty2) {
        // Calculate total from product prices and quantities
        double total = (product1.getPrice() * qty1) + (product2.getPrice() * qty2);
        Order order = orderRepository.save(new Order(customer, date, status, total));
        orderItemRepository.save(new OrderItem(order, product1, qty1, product1.getPrice()));
        orderItemRepository.save(new OrderItem(order, product2, qty2, product2.getPrice()));
    }
}

Your domain layer is complete. You have entities, repositories, and a full dataset ready for your AI assistant to query. Next up, you will create the tool classes that let the LLM call your repositories directly.

Implement Tool Calling with @Tool

Your database is seeded with customers, products, and orders. Right now, the only way to query that data is through code or manual SQL.

In this step, you will give the LLM direct access to your repositories by creating tool classes. When a user asks a natural language question, the model will decide which tool to call, execute it, and return real data from your H2 database.

In this step, get ready to:
  • Create an OrderTools class with @Tool-annotated methods for querying orders.
  • Create a ProductTools class with @Tool-annotated methods for querying products.
  • Wire tools into a ChatClient and expose a POST /api/chat endpoint.
Create the OrderTools class

Spring AI's tool calling lets an LLM request data from your application at runtime. You annotate plain Java methods with @Tool and @ToolParam, and the framework handles the rest.

  • In your IDE, create a new package called tools inside src/main/java/com/example/demo/.
  • Create a new file called OrderTools.java inside the tools package, then paste this code:
package com.example.demo.tools;

import com.example.demo.entity.Order;
import com.example.demo.repository.OrderRepository;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;
import org.springframework.stereotype.Component;

import java.time.LocalDate;
import java.util.List;

@Component
public class OrderTools {

    private final OrderRepository orderRepository;

    public OrderTools(OrderRepository orderRepository) {
        this.orderRepository = orderRepository;
    }

    // Lets the LLM look up orders by customer name
    @Tool(description = "Get orders placed by a customer by name")
    public List<Order> getOrdersByCustomerName(@ToolParam(description = "Customer name to search") String customerName) {
        return orderRepository.findByCustomerNameContainingIgnoreCase(customerName);
    }

    // Lets the LLM find recent orders within a time window
    @Tool(description = "Get orders placed in the last N days")
    public List<Order> getRecentOrders(@ToolParam(description = "Number of days to look back") int days) {
        LocalDate from = LocalDate.now().minusDays(days);
        return orderRepository.findByOrderDateBetween(from, LocalDate.now());
    }

    // Lets the LLM retrieve a single order with full details
    @Tool(description = "Get details of a specific order by ID")
    public Order getOrderDetails(@ToolParam(description = "Order ID") Long orderId) {
        return orderRepository.findById(orderId).orElse(null);
    }
}

What does this code do?

Each method annotated with @Tool becomes a capability the LLM can invoke. The description attribute tells the model when to use each tool.

The @ToolParam annotation describes each parameter so the model knows what values to pass. Spring AI serializes your return objects to JSON automatically for the model to interpret.

  • Save OrderTools.java.

Seeing import errors?

Make sure spring-ai-starter-model-google-genai is in your pom.xml and the Spring AI BOM is set to version 2.0.0. Run a Maven reimport in your IDE if the annotations are unresolved.

help me fix import errors for @Tool and @ToolParam in my OrderTools class

Create the ProductTools class
  • Create a new file called ProductTools.java inside the same tools package, then paste this code:
package com.example.demo.tools;

import com.example.demo.entity.Product;
import com.example.demo.repository.ProductRepository;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;
import org.springframework.stereotype.Component;

import java.util.List;

@Component
public class ProductTools {

    private final ProductRepository productRepository;

    public ProductTools(ProductRepository productRepository) {
        this.productRepository = productRepository;
    }

    // Lets the LLM find products running low on inventory
    @Tool(description = "Get products with stock below a threshold")
    public List<Product> getLowStockProducts(@ToolParam(description = "Stock threshold") int threshold) {
        return productRepository.findByStockQuantityLessThan(threshold);
    }

    // Lets the LLM search products by a keyword in their name
    @Tool(description = "Search products by keyword")
    public List<Product> searchProducts(@ToolParam(description = "Keyword to search in product name") String keyword) {
        return productRepository.findByNameContainingIgnoreCase(keyword);
    }
}

What does this code do?

This component gives the LLM two product-focused capabilities: checking inventory levels and searching by keyword. The @Component annotation lets Spring manage this class as a bean, so it can be injected into the ChatClient later.

  • Save ProductTools.java.

ProductRepository not found?

Confirm that ProductRepository.java exists in the repository package and includes the findByStockQuantityLessThan and findByNameContainingIgnoreCase methods from Step 2.

help me resolve missing repository methods in my ProductTools class

Wire tools into a ChatClient and expose the /api/chat endpoint

Spring Boot auto-configures a ChatClient.Builder bean for you. You can inject it into a controller and register your tools using .defaultTools().

  • Create a new package called controller inside src/main/java/com/example/demo/.
  • Create a new file called ChatController.java inside the controller package, then paste this code:
package com.example.demo.controller;

import com.example.demo.tools.OrderTools;
import com.example.demo.tools.ProductTools;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.web.bind.annotation.*;

import java.util.Map;

@RestController
@RequestMapping("/api")
public class ChatController {

    private final ChatClient chatClient;

    // Injects the auto-configured builder and registers both tool classes
    public ChatController(ChatClient.Builder builder, OrderTools orderTools, ProductTools productTools) {
        this.chatClient = builder
            .defaultTools(orderTools, productTools)
            .build();
    }

    // Accepts a JSON body with a "message" field and returns the AI response
    @PostMapping("/chat")
    public String chat(@RequestBody Map<String, String> body) {
        return chatClient.prompt(body.get("message")).call().content();
    }
}

What does this code do?

The constructor receives a ChatClient.Builder that Spring Boot auto-configures with your Gemini model. Calling .defaultTools(orderTools, productTools) registers every @Tool-annotated method from both classes as available capabilities.

The ToolCallingAdvisor is auto-registered by ChatClient in Spring AI 2.0. It handles the tool-call loop transparently: the model requests a tool, the advisor executes it, and the result is fed back to the model until a final answer is produced.

  • Save ChatController.java.
  • Restart your application by running:
./mvnw spring-boot:run
  • Once the application starts, test tool calling by running this curl command in a new terminal:
curl -X POST http://localhost:8080/api/chat -H "Content-Type: application/json" -d "{\"message\": \"What orders did Alice place?\"}"

You should see a response from Gemini that includes real order data pulled from your H2 database. The model identified that it needed the getOrdersByCustomerName tool, invoked it with "Alice", and formatted the results into a natural language answer.

Getting an empty or generic response?

If the response does not include real order data, check that your DataInitializer seeds a customer named "Alice". Also confirm both tool classes are annotated with @Component so Spring can inject them into the controller.

If you see a connection or authentication error, verify your API key in application.properties is correct and that spring.ai.google.genai.chat.model is set to gemini-2.5-flash.

help me debug why my /api/chat endpoint is not returning real data from my tools

✔️ Awesome, I've got everything!

Great. Double check you have saved all three files before moving on.

ⓧ I'd like to double check the full code

Here is the full code for each file created in this step.

package com.example.demo.tools;

import com.example.demo.entity.Order;
import com.example.demo.repository.OrderRepository;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;
import org.springframework.stereotype.Component;

import java.time.LocalDate;
import java.util.List;

@Component
public class OrderTools {

    private final OrderRepository orderRepository;

    public OrderTools(OrderRepository orderRepository) {
        this.orderRepository = orderRepository;
    }

    @Tool(description = "Get orders placed by a customer by name")
    public List<Order> getOrdersByCustomerName(@ToolParam(description = "Customer name to search") String customerName) {
        return orderRepository.findByCustomerNameContainingIgnoreCase(customerName);
    }

    @Tool(description = "Get orders placed in the last N days")
    public List<Order> getRecentOrders(@ToolParam(description = "Number of days to look back") int days) {
        LocalDate from = LocalDate.now().minusDays(days);
        return orderRepository.findByOrderDateBetween(from, LocalDate.now());
    }

    @Tool(description = "Get details of a specific order by ID")
    public Order getOrderDetails(@ToolParam(description = "Order ID") Long orderId) {
        return orderRepository.findById(orderId).orElse(null);
    }
}
package com.example.demo.tools;

import com.example.demo.entity.Product;
import com.example.demo.repository.ProductRepository;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;
import org.springframework.stereotype.Component;

import java.util.List;

@Component
public class ProductTools {

    private final ProductRepository productRepository;

    public ProductTools(ProductRepository productRepository) {
        this.productRepository = productRepository;
    }

    @Tool(description = "Get products with stock below a threshold")
    public List<Product> getLowStockProducts(@ToolParam(description = "Stock threshold") int threshold) {
        return productRepository.findByStockQuantityLessThan(threshold);
    }

    @Tool(description = "Search products by keyword")
    public List<Product> searchProducts(@ToolParam(description = "Keyword to search in product name") String keyword) {
        return productRepository.findByNameContainingIgnoreCase(keyword);
    }
}
package com.example.demo.controller;

import com.example.demo.tools.OrderTools;
import com.example.demo.tools.ProductTools;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.web.bind.annotation.*;

import java.util.Map;

@RestController
@RequestMapping("/api")
public class ChatController {

    private final ChatClient chatClient;

    public ChatController(ChatClient.Builder builder, OrderTools orderTools, ProductTools productTools) {
        this.chatClient = builder
            .defaultTools(orderTools, productTools)
            .build();
    }

    @PostMapping("/chat")
    public String chat(@RequestBody Map<String, String> body) {
        return chatClient.prompt(body.get("message")).call().content();
    }
}

Your LLM can now query real data through your tools. Next up, you will add structured output so the model can parse natural language into typed Java objects for precise database filtering.

Add Structured Output with .entity()

Your /api/chat endpoint returns free-text answers from the LLM. That works for conversational queries, but sometimes your application needs machine-readable data it can act on programmatically.

In this step, you will use Spring AI's structured output feature to parse natural language queries into typed Java records. The LLM reads the user's intent and fills in a strongly-typed filter object that your code can pass directly to the database.

In this step, get ready to:
  • Define Java record classes that represent query filters.
  • Create a structured output endpoint that parses natural language into typed objects.
  • Execute a database query using the parsed filter and return real results.
Define the query filter records

Java records are lightweight data carriers. You will define two records that represent the structured shape the LLM should output when parsing a user's natural language query.

  • Create a new package called model inside src/main/java/com/example/demo/.
  • Inside the model package, create a file called OrderFilter.java with the following content:
package com.example.demo.model;

import java.time.LocalDate;

// Represents a structured filter the LLM fills from natural language
public record OrderFilter(String customerName, LocalDate fromDate, LocalDate toDate, String status) {}
  • In the same model package, create a file called ProductSearchCriteria.java with the following content:
package com.example.demo.model;

// Represents product search criteria parsed from natural language
public record ProductSearchCriteria(String keyword, Double maxPrice, Boolean inStockOnly) {}

What are these records for?

OrderFilter defines the fields the LLM should extract from an order-related query: a customer name, a date range, and an order status.

ProductSearchCriteria captures product search intent: a keyword, a maximum price, and whether to filter by stock availability. Spring AI inspects these record fields and instructs the LLM to return JSON matching the schema.

  • Save both files.
  • Confirm the files exist by checking the model package in your IDE's project explorer. You should see OrderFilter.java and ProductSearchCriteria.java listed.

Don't see the model package?

Make sure you created the model directory inside src/main/java/com/example/demo/, not at the project root. The package declaration must match the folder path exactly.

Help me create the model package in the right location.

Create the parse-query endpoint

Now you will add a new endpoint to your controller that accepts a natural language query, asks the LLM to parse it into an OrderFilter, and then uses that filter to query the database.

  • Open ChatController.java in src/main/java/com/example/demo/controller/.
  • Replace the entire file contents with the following updated code:
package com.example.demo.controller;

import com.example.demo.entity.Order;
import com.example.demo.model.OrderFilter;
import com.example.demo.repository.OrderRepository;
import com.example.demo.tools.OrderTools;
import com.example.demo.tools.ProductTools;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.web.bind.annotation.*;

import java.util.List;
import java.util.Map;

@RestController
@RequestMapping("/api")
public class ChatController {

    private final ChatClient chatClient;
    private final OrderRepository orderRepository;

    public ChatController(ChatClient.Builder builder, OrderTools orderTools, ProductTools productTools, OrderRepository orderRepository) {
        this.chatClient = builder
            .defaultTools(orderTools, productTools)
            .build();
        // Store repository for direct queries after parsing
        this.orderRepository = orderRepository;
    }

    @PostMapping("/chat")
    public String chat(@RequestBody Map<String, String> body) {
        return chatClient.prompt(body.get("message")).call().content();
    }

    @PostMapping("/parse-query")
    public Map<String, Object> parseQuery(@RequestBody Map<String, String> body) {
        String query = body.get("query");

        // Ask the LLM to parse natural language into a typed OrderFilter
        OrderFilter filter = chatClient.prompt("Parse this query into an OrderFilter: " + query)
            .call().entity(OrderFilter.class);

        // Use the parsed filter to query the database directly
        List<Order> results = orderRepository.findByCustomerNameContainingIgnoreCase(
            filter.customerName() != null ? filter.customerName() : "");

        // Return both the parsed filter and the matching results
        return Map.of("filter", filter, "results", results);
    }
}

What does this code do?

The parseQuery method takes a natural language string from the request body. It passes that string to the LLM with a prompt asking it to extract an OrderFilter.

The .call().entity(OrderFilter.class) chain is where the magic happens. Spring AI internally uses a BeanOutputConverter to generate a JSON schema from your record's fields and appends it to the prompt. The LLM returns JSON matching that schema, and Spring AI deserializes it into your Java record.

Once the filter is populated, the code queries the database using the extracted customerName and returns both the parsed filter and the real results.

  • Save ChatController.java.

Seeing import errors?

Make sure the import com.example.demo.model.OrderFilter statement is present at the top of the file. If your IDE shows a red underline on OrderFilter, verify that the record's package declaration is com.example.demo.model.

Help me fix import errors in my ChatController.

Test the structured output endpoint

Time to verify that the LLM correctly parses natural language into your typed Java record and that the database query returns matching results.

  • Restart your Spring Boot application to pick up the new code.
  • Test the endpoint by running this command in your terminal:
curl -X POST http://localhost:8080/api/parse-query -H "Content-Type: application/json" -d "{\"query\": \"pending orders from the last 7 days\"}"

What should I see?

You should receive a JSON response containing two keys: filter and results. The filter object shows how the LLM interpreted the query, with fields like status set to something like pending and fromDate/toDate populated with dates from the last 7 days.

The results array contains the actual order records from your H2 database that matched the parsed filter.

  • Try a second query to confirm it handles different inputs:
curl -X POST http://localhost:8080/api/parse-query -H "Content-Type: application/json" -d "{\"query\": \"Find orders from Alice\"}"

This time the filter should have customerName set to Alice, and the results should include orders belonging to that customer from your seed data.

Getting an error or empty results?

If you see a 500 error, check that your application restarted successfully and that the OrderFilter record compiles without errors. The record must be a top-level class with a proper package declaration.

If the filter fields are all null, the LLM may not have parsed the query correctly. Try rephrasing your input or check that your API key is still valid in application.properties.

Help me debug my /api/parse-query endpoint.

Why .entity() instead of parsing JSON manually?

The .entity() method handles the entire conversion pipeline. It generates a JSON schema from your Java record, appends format instructions to the prompt, and deserializes the response. You get compile-time type safety without writing any parsing logic.

✔️ Awesome, I've got everything!

Great. Double check you have saved all three files before moving on.

ⓧ I'd like to double check the full code

Here is the complete ChatController.java at this point:

package com.example.demo.controller;

import com.example.demo.entity.Order;
import com.example.demo.model.OrderFilter;
import com.example.demo.repository.OrderRepository;
import com.example.demo.tools.OrderTools;
import com.example.demo.tools.ProductTools;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.web.bind.annotation.*;

import java.util.List;
import java.util.Map;

@RestController
@RequestMapping("/api")
public class ChatController {

    private final ChatClient chatClient;
    private final OrderRepository orderRepository;

    public ChatController(ChatClient.Builder builder, OrderTools orderTools, ProductTools productTools, OrderRepository orderRepository) {
        this.chatClient = builder
            .defaultTools(orderTools, productTools)
            .build();
        this.orderRepository = orderRepository;
    }

    @PostMapping("/chat")
    public String chat(@RequestBody Map<String, String> body) {
        return chatClient.prompt(body.get("message")).call().content();
    }

    @PostMapping("/parse-query")
    public Map<String, Object> parseQuery(@RequestBody Map<String, String> body) {
        String query = body.get("query");

        OrderFilter filter = chatClient.prompt("Parse this query into an OrderFilter: " + query)
            .call().entity(OrderFilter.class);

        List<Order> results = orderRepository.findByCustomerNameContainingIgnoreCase(
            filter.customerName() != null ? filter.customerName() : "");

        return Map.of("filter", filter, "results", results);
    }
}

And here are the two model records:

package com.example.demo.model;

import java.time.LocalDate;

public record OrderFilter(String customerName, LocalDate fromDate, LocalDate toDate, String status) {}
package com.example.demo.model;

public record ProductSearchCriteria(String keyword, Double maxPrice, Boolean inStockOnly) {}

Your API now understands natural language queries and converts them into typed objects your code can use. Next up, you will add chat memory so the LLM remembers context across multiple messages in a conversation.

Add Chat Memory for Multi-Turn Conversations

Your assistant can now parse natural language into structured filters and query your database directly. But each request exists in isolation. If you ask about Alice's orders and then follow up with "What was the total?", the LLM has no idea who "the total" refers to.

In this step, you will add chat memory so the assistant remembers prior messages within a conversation. Spring AI provides MessageWindowChatMemory to store a sliding window of messages, and MessageChatMemoryAdvisor to inject that history into every prompt automatically.

In this step, get ready to:
  • Configure MessageWindowChatMemory with an in-memory repository.
  • Wire the memory advisor into the ChatClient.
  • Test multi-turn conversations where follow-up questions resolve context from prior messages.
Configure chat memory in the constructor

The LLM needs a place to store and retrieve conversation history. MessageWindowChatMemory keeps a sliding window of messages (you will set it to 10) and evicts older ones automatically.

  • In ChatController.java, find the constructor. It currently looks like this:
public ChatController(ChatClient.Builder builder, OrderTools orderTools, ProductTools productTools, OrderRepository orderRepository) {
    this.chatClient = builder
        .defaultTools(orderTools, productTools)
        .build();
    this.orderRepository = orderRepository;
}
  • Replace the entire constructor body with the following code, which adds memory configuration before the ChatClient build:
public ChatController(ChatClient.Builder builder, OrderTools orderTools, ProductTools productTools, OrderRepository orderRepository) {
    // Create a sliding-window memory that keeps the last 10 messages per conversation
    MessageWindowChatMemory memory = MessageWindowChatMemory.builder()
        .chatMemoryRepository(new InMemoryChatMemoryRepository())
        .maxMessages(10)
        .build();

    // Build the ChatClient with tools AND the memory advisor
    this.chatClient = builder
        .defaultTools(orderTools, productTools)
        .defaultAdvisors(new MessageChatMemoryAdvisor(memory))
        .build();
    this.orderRepository = orderRepository;
}

What does this code do?

MessageWindowChatMemory.builder() creates a memory store backed by InMemoryChatMemoryRepository (a simple ConcurrentHashMap). The .maxMessages(10) call limits the window to the 10 most recent messages per conversation, evicting older ones automatically.

MessageChatMemoryAdvisor is an advisor that intercepts every ChatClient call. It loads the stored conversation history and appends it to the prompt so the LLM has context from prior turns.

  • Your IDE will prompt you to add imports for MessageWindowChatMemory, InMemoryChatMemoryRepository, and MessageChatMemoryAdvisor. Accept the suggested imports from the org.springframework.ai.chat.memory package.
Update the chat endpoint to accept a conversation ID

Spring AI 2.0 requires a conversation ID on every call to a memory advisor. Without it, the advisor throws an IllegalArgumentException. You will pass this ID as a request header so different clients can maintain separate conversation threads.

  • In the same file, find the existing chat method:
@PostMapping("/chat")
public String chat(@RequestBody Map<String, String> body) {
    return chatClient.prompt(body.get("message")).call().content();
}
  • Replace the entire method with this updated version that reads a conversationId header and passes it to the advisor:
@PostMapping("/chat")
public String chat(@RequestBody Map<String, String> body,
                   @RequestHeader(value = "conversationId", defaultValue = "default") String conversationId) {
    // Pass the conversation ID so the memory advisor can load/store the right thread
    return chatClient.prompt(body.get("message"))
        .advisors(a -> a.param(ChatMemory.CONVERSATION_ID, conversationId))
        .call().content();
}

What does this code do?

@RequestHeader(value = "conversationId", defaultValue = "default") extracts the conversationId header from the incoming HTTP request. If a client does not send one, it falls back to the string "default".

The .advisors(a -> a.param(ChatMemory.CONVERSATION_ID, conversationId)) call supplies the conversation ID at runtime. The MessageChatMemoryAdvisor uses this key to load prior messages for the correct conversation thread before calling the LLM.

  • Add the import for ChatMemory from org.springframework.ai.chat.memory.ChatMemory when your IDE prompts you.
  • Save ChatController.java.

Seeing an import error?

If your IDE cannot resolve MessageWindowChatMemory or ChatMemory, make sure your pom.xml includes the spring-ai-starter-model-google-genai dependency and the spring-ai-bom version 2.0.0 in dependency management. Run a Maven reimport to refresh your classpath.

help me fix import errors for Spring AI chat memory classes

✔️ Awesome, I've got everything!

Great. Make sure you have saved ChatController.java before moving on.

ⓧ I'd like to double check the full code

Here is the complete ChatController.java after all changes in this step:

@RestController
@RequestMapping("/api")
public class ChatController {
    private final ChatClient chatClient;
    private final OrderRepository orderRepository;

    public ChatController(ChatClient.Builder builder, OrderTools orderTools, ProductTools productTools, OrderRepository orderRepository) {
        MessageWindowChatMemory memory = MessageWindowChatMemory.builder()
            .chatMemoryRepository(new InMemoryChatMemoryRepository())
            .maxMessages(10)
            .build();

        this.chatClient = builder
            .defaultTools(orderTools, productTools)
            .defaultAdvisors(new MessageChatMemoryAdvisor(memory))
            .build();
        this.orderRepository = orderRepository;
    }

    @PostMapping("/chat")
    public String chat(@RequestBody Map<String, String> body,
                       @RequestHeader(value = "conversationId", defaultValue = "default") String conversationId) {
        return chatClient.prompt(body.get("message"))
            .advisors(a -> a.param(ChatMemory.CONVERSATION_ID, conversationId))
            .call().content();
    }

    @PostMapping("/parse-query")
    public Map<String, Object> parseQuery(@RequestBody Map<String, String> body) {
        String query = body.get("query");
        OrderFilter filter = chatClient.prompt("Parse this query into an OrderFilter: " + query)
            .call().entity(OrderFilter.class);
        List<Order> results = orderRepository.findByCustomerNameContainingIgnoreCase(
            filter.customerName() != null ? filter.customerName() : "");
        return Map.of("filter", filter, "results", results);
    }
}
Test multi-turn conversations

Time to prove that memory works. You will ask a question about a specific customer, then ask a follow-up that only makes sense if the LLM remembers the prior message.

  • Restart your application so the new constructor takes effect.
  • Send the first message, asking about Alice's orders, by running this command:
curl -X POST http://localhost:8080/api/chat -H "Content-Type: application/json" -H "conversationId: session-1" -d "{\"message\": \"What orders did Alice place?\"}"
  • You should see a response listing Alice's orders with details from your H2 database.
  • Now send a follow-up question that relies on the prior context by running:
curl -X POST http://localhost:8080/api/chat -H "Content-Type: application/json" -H "conversationId: session-1" -d "{\"message\": \"What was the total amount?\"}"

The LLM should respond with the total amount of Alice's orders without you needing to mention Alice again. The conversationId: session-1 header ties both requests to the same memory thread.

Why does this work?

When the second request arrives, MessageChatMemoryAdvisor loads the prior messages for session-1 from the in-memory store. It appends them to the prompt so the LLM sees the full conversation. The model resolves "the total amount" to Alice's orders because it can see the earlier exchange.

Follow-up not resolving context?

Make sure both curl commands use the same conversationId header value. Different IDs create separate memory threads. Also confirm you restarted the application after saving the constructor changes.

help me debug why my chat memory is not working across requests

Your assistant now remembers context across messages. Next up, you will package the entire application into a Docker container so it is easy to deploy and share.

Dockerize the Application

Your operations assistant is fully functional with tool calling, structured output, and chat memory. The next step is packaging it so anyone can run it without configuring a local Java environment.

In this step, you will containerize the application using Docker. A multi-stage Dockerfile keeps the final image small by separating the build environment from the runtime. Then Docker Compose ties everything together into a single command that builds, configures, and starts the app.

In this step, get ready to:
  • Create a multi-stage Dockerfile that builds and runs the Spring Boot application.
  • Write a Docker Compose file that passes your API key as an environment variable.
  • Build and verify the containerized application responds to chat requests.
Create a multi-stage Dockerfile

A multi-stage build uses one image to compile your code and a different, smaller image to run it. The build stage needs the full JDK and Maven. The runtime stage only needs the JRE and the compiled JAR.

  • Create a new file called Dockerfile in the root of your project folder (the same directory that contains pom.xml).
  • Paste the following content into Dockerfile:
# Stage 1: Build
FROM eclipse-temurin:25-jdk AS builder
WORKDIR /app
COPY . .
RUN ./mvnw clean package -DskipTests

# Stage 2: Runtime
FROM eclipse-temurin:25-jre
WORKDIR /app
COPY --from=builder /app/target/*.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]

What does this Dockerfile do?

Stage 1 (builder) uses the full JDK image to compile the project with Maven. The -DskipTests flag skips tests during the Docker build to keep it fast.

Stage 2 starts from a slim JRE-only image. It copies just the compiled JAR from the build stage, exposes port 8080, and runs the application. The final image does not contain Maven, source code, or the full JDK.

  • Save Dockerfile.
  • Confirm the file exists by running:
ls Dockerfile

You should see Dockerfile listed in the output.

Don't see the file?

Make sure you created Dockerfile in the project root (the folder containing pom.xml), not inside src/ or another subdirectory. The file name has no extension and starts with a capital D.

help me find where to place my Dockerfile

Create a Docker Compose file and update application.properties

Docker Compose defines how to build and run your container in a single YAML file. It also passes your API key as an environment variable so the key never gets baked into the image.

First, you need to update application.properties so that Spring Boot reads the API key from an environment variable at runtime.

  • In src/main/resources/application.properties, find this line:
spring.ai.google.genai.api-key=your-key-here
  • Replace it with this line, which reads from an environment variable with a fallback default:
spring.ai.google.genai.api-key=${SPRING_AI_GOOGLE_GENAI_API_KEY:your-key-here}

What does the ${...} syntax do?

Spring Boot's property placeholder syntax ${ENV_VAR:default} reads the value from the environment variable SPRING_AI_GOOGLE_GENAI_API_KEY at runtime. If the variable is not set, it falls back to your-key-here. This means your local development still works and Docker can inject the real key.

  • Save application.properties.
  • Now create a new file called docker-compose.yml in the project root (next to Dockerfile).
  • Paste the following content into docker-compose.yml:
version: '3.8'
services:
  app:
    build: .
    ports:
      - "8080:8080"
    environment:
      - SPRING_AI_GOOGLE_GENAI_API_KEY=${SPRING_AI_GOOGLE_GENAI_API_KEY}

What does this compose file do?

The build: . directive tells Docker Compose to build an image using the Dockerfile in the current directory.

The ports mapping exposes port 8080 from the container to your host machine. The environment section passes your host's SPRING_AI_GOOGLE_GENAI_API_KEY variable into the container so the app can authenticate with the Gemini API.

  • Save docker-compose.yml.
  • Confirm both Docker files exist by running:
ls Dockerfile docker-compose.yml

You should see both files listed.

File not found?

Both Dockerfile and docker-compose.yml must live in the same directory as pom.xml. Run ls pom.xml to confirm you are in the right place.

help me organize my project files for Docker

✔️ Awesome, I've got everything!

Great. Make sure both files are saved before you move on.

ⓧ I'd like to double check the full code

Here is the complete Dockerfile:

# Stage 1: Build
FROM eclipse-temurin:25-jdk AS builder
WORKDIR /app
COPY . .
RUN ./mvnw clean package -DskipTests

# Stage 2: Runtime
FROM eclipse-temurin:25-jre
WORKDIR /app
COPY --from=builder /app/target/*.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]

Here is the complete docker-compose.yml:

version: '3.8'
services:
  app:
    build: .
    ports:
      - "8080:8080"
    environment:
      - SPRING_AI_GOOGLE_GENAI_API_KEY=${SPRING_AI_GOOGLE_GENAI_API_KEY}

Here is the updated application.properties:

spring.ai.google.genai.api-key=${SPRING_AI_GOOGLE_GENAI_API_KEY:your-key-here}
spring.ai.google.genai.chat.model=gemini-2.5-flash
spring.h2.console.enabled=true
spring.datasource.url=jdbc:h2:mem:testdb
Build and run with Docker Compose

Now you will build the Docker image and start the container. Docker Compose needs your API key available as an environment variable on your host machine.

  • First, make sure Docker Desktop is running.
  • Export your Google AI Studio API key in your terminal by running this command (replace your-key-here with your actual key):
export SPRING_AI_GOOGLE_GENAI_API_KEY=your-key-here

Windows users

On Windows PowerShell, use this instead:

$env:SPRING_AI_GOOGLE_GENAI_API_KEY="your-key-here"
  • Build and start the application by running:
docker compose up --build

What does this command do?

The --build flag tells Docker Compose to rebuild the image from the Dockerfile before starting the container. This ensures any code changes are included. The first build takes a few minutes because it downloads the base images and all Maven dependencies.

  • Wait for the build to finish. You will see Maven downloading dependencies and eventually Spring Boot's startup banner in the logs.
  • Once you see a log line like Started DemoApplication, open a new terminal tab and test the endpoint by running:
curl -X POST http://localhost:8080/api/chat -H "Content-Type: application/json" -d "{\"message\": \"What products do you have?\"}"

You should see a response from Gemini that describes products from your seeded H2 database. The LLM invokes your tool functions inside the container to fetch real data.

Container fails to start?

If you see an error about the API key being empty, double check that you exported SPRING_AI_GOOGLE_GENAI_API_KEY in the same terminal session where you ran docker compose up --build.

If the build fails at the Maven step, make sure your pom.xml compiles locally first with ./mvnw clean compile (macOS/Linux) or mvnw.cmd clean compile (Windows).

If port 8080 is already in use, stop any local instance of the application before running the container.

help me debug my Docker build

Secret mission

Add Streaming Responses with Server-Sent Events

Your current /api/chat endpoint waits for the entire LLM response before sending anything back. Real production APIs stream tokens as they arrive, giving users instant feedback. In this mission, you'll add a streaming endpoint using Server-Sent Events and Spring AI's reactive stream() API.

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally with a free-tier API key, so there are no ongoing costs.

Resources you used:

  • Docker containers and images built for the application (local, free).
  • Google AI Studio API key (free tier, no charges).
  • Local project folder with Spring Boot source code.

Keep everything running

No action needed. Choose this if you're still actively building or experimenting with the assistant.

  • Your Docker containers continue running and serving requests on port 8080.
  • Your Google AI Studio API key stays active on the free tier with no charges.
  • Your project folder remains on your Desktop for future development.

Pause - I'll come back to this later

Shut down running containers to free up memory. Your code and API key remain intact for when you return.

  • Stop and remove the Docker containers by running:
docker compose down
  • Your source code and application.properties (with your API key) stay on disk.
  • To resume later, run docker compose up --build from your project folder.

Delete - I don't want to use this again

Remove all project resources and start fresh. This removes containers, images, and local files.

  • Stop containers and remove the built Docker images by running:
docker compose down --rmi all
  • Delete the local project folder. On macOS or Linux:
cd ~/Desktop
rm -rf demo
  • On Windows:
cd ~\Desktop
rmdir /s /q demo

Nice Work!

Nice Work!

You just built a production-style AI-powered operations assistant using Spring Boot 4.x and Spring AI 2.0.0. Users can ask natural language questions and get real answers backed by your database.

You've learned how to:

  • Build a Spring AI REST API with @Tool and @ToolParam annotations that let the LLM invoke your backend methods and query a live H2 database.
  • Parse natural language into typed Java records using ChatClient's .entity() structured output, then execute real queries against the database with the parsed filters.
  • Add multi-turn conversation memory with MessageWindowChatMemory and MessageChatMemoryAdvisor, and Dockerize the full application with a multi-stage build.
  • Secret Mission: Added streaming responses with Server-Sent Events using Spring AI's reactive stream() API.

Ready to quiz yourself?