Building an AI-Powered Chatbot with LangChain4j and Spring Boot 3

Introduction

The Java ecosystem has historically been a step behind Python when it comes to LLM tooling, but that gap has closed dramatically. LangChain4j — the Java port of the popular LangChain framework — has matured into a production-ready library that gives Spring Boot developers first-class access to LLMs, chat memory, tool calling, and RAG pipelines without leaving the JVM.

In this tutorial we will build a fully functional customer-support chatbot from the ground up. By the end you will have:

  • A Spring Boot 3.5 application with LangChain4j auto-configuration
  • A declarative @AiService interface backed by Claude claude-sonnet-5 (or any other LLM)
  • Stateful, per-user conversation memory with a sliding window
  • A custom @Tool that lets the LLM look up live order status
  • A REST API endpoint and a simple HTML chat UI
  • A full suite of unit and integration tests using AiServiceMock

All code uses com.vimleshpandey.demo as the base package and targets Java 21 + Spring Boot 3.5.


Prerequisites

RequirementVersion
Java21+
Spring Boot3.5.x
LangChain4j1.0.0 (stable)
Maven3.9+
An LLM API keyAnthropic, OpenAI, or any supported provider

Basic familiarity with Spring Boot and REST APIs is assumed.


Project Setup

pom.xml

Create a new Maven project with the following pom.xml:

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
             https://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>

  <parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>3.5.3</version>
    <relativePath/>
  </parent>

  <groupId>com.vimleshpandey.demo</groupId>
  <artifactId>ai-chatbot</artifactId>
  <version>1.0.0</version>
  <name>ai-chatbot</name>
  <description>AI-powered chatbot with LangChain4j and Spring Boot 3</description>

  <properties>
    <java.version>21</java.version>
    <langchain4j.version>1.0.0</langchain4j.version>
  </properties>

  <dependencies>
    <!-- Spring Boot -->
    <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>

    <!-- LangChain4j Spring Boot Starter (auto-configures everything) -->
    <dependency>
      <groupId>dev.langchain4j</groupId>
      <artifactId>langchain4j-spring-boot-starter</artifactId>
      <version>${langchain4j.version}</version>
    </dependency>

    <!-- Anthropic Claude provider -->
    <dependency>
      <groupId>dev.langchain4j</groupId>
      <artifactId>langchain4j-anthropic-spring-boot-starter</artifactId>
      <version>${langchain4j.version}</version>
    </dependency>

    <!-- Lombok -->
    <dependency>
      <groupId>org.projectlombok</groupId>
      <artifactId>lombok</artifactId>
      <optional>true</optional>
    </dependency>

    <!-- Test -->
    <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-test</artifactId>
      <scope>test</scope>
    </dependency>
    <dependency>
      <groupId>dev.langchain4j</groupId>
      <artifactId>langchain4j-spring-boot-tests</artifactId>
      <version>${langchain4j.version}</version>
      <scope>test</scope>
    </dependency>
  </dependencies>

  <build>
    <plugins>
      <plugin>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-maven-plugin</artifactId>
        <configuration>
          <excludes>
            <exclude>
              <groupId>org.projectlombok</groupId>
              <artifactId>lombok</artifactId>
            </exclude>
          </excludes>
        </configuration>
      </plugin>
    </plugins>
  </build>
</project>

application.yml

spring:
  application:
    name: ai-chatbot
  datasource:
    url: jdbc:h2:mem:chatbot_db
    driver-class-name: org.h2.Driver
  jpa:
    hibernate:
      ddl-auto: create-drop
    show-sql: false
  h2:
    console:
      enabled: true

langchain4j:
  anthropic:
    chat-model:
      api-key: ${ANTHROPIC_API_KEY}
      model-name: claude-sonnet-5-20260901
      max-tokens: 1024
      temperature: 0.7
      log-requests: false
      log-responses: false
  # How many messages to keep in the sliding window
  chat-memory:
    max-messages: 20

server:
  port: 8080

Defining the AI Service

LangChain4j's killer feature is the @AiService annotation. You declare an interface, add annotation-based prompts, and the framework generates the implementation at startup — no boilerplate required.

CustomerSupportAssistant.java

package com.vimleshpandey.demo.ai;

import dev.langchain4j.service.MemoryId;
import dev.langchain4j.service.SystemMessage;
import dev.langchain4j.service.UserMessage;
import dev.langchain4j.service.spring.AiService;

@AiService
public interface CustomerSupportAssistant {

    @SystemMessage("""
            You are a friendly customer-support agent for ShopEasy, an online store.
            You help customers with order tracking, product questions, and returns.
            Always be polite and concise. When you do not know something, say so honestly.
            Never make up order details — always use the available tools to look them up.
            Today's date is {{current_date}}.
            """)
    String chat(@MemoryId String sessionId, @UserMessage String userMessage);
}

Key annotations:

  • @AiService — registers this interface as a Spring bean backed by the configured LLM

  • @SystemMessage — the system prompt, evaluated as a template at runtime

  • @MemoryId — tells LangChain4j which memory slot to use; each unique sessionId gets its own conversation history

  • @UserMessage — the human turn


Implementing Chat Memory

By default the LangChain4j Spring Boot starter creates an in-memory MessageWindowChatMemory. This is fine for development, but for production you want memory backed by a persistent store. LangChain4j provides a ChatMemoryStore SPI for exactly this.

ChatMemoryStore implementation (H2/JPA)

package com.vimleshpandey.demo.ai;

import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import dev.langchain4j.data.message.ChatMessage;
import dev.langchain4j.store.memory.chat.ChatMemoryStore;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;

import java.util.Collections;
import java.util.List;

@Component
@RequiredArgsConstructor
@Slf4j
public class JpaChatMemoryStore implements ChatMemoryStore {

    private final ChatSessionRepository repository;
    private final ObjectMapper objectMapper;

    @Override
    public List<ChatMessage> getMessages(Object memoryId) {
        return repository.findById(memoryId.toString())
                .map(session -> deserialize(session.getMessages()))
                .orElse(Collections.emptyList());
    }

    @Override
    public void updateMessages(Object memoryId, List<ChatMessage> messages) {
        ChatSession session = repository.findById(memoryId.toString())
                .orElse(new ChatSession(memoryId.toString()));
        session.setMessages(serialize(messages));
        repository.save(session);
    }

    @Override
    public void deleteMessages(Object memoryId) {
        repository.deleteById(memoryId.toString());
    }

    private String serialize(List<ChatMessage> messages) {
        try {
            return objectMapper.writeValueAsString(messages);
        } catch (Exception e) {
            log.error("Failed to serialize chat messages", e);
            return "[]";
        }
    }

    private List<ChatMessage> deserialize(String json) {
        try {
            return objectMapper.readValue(json,
                    new TypeReference<List<ChatMessage>>() {});
        } catch (Exception e) {
            log.error("Failed to deserialize chat messages", e);
            return Collections.emptyList();
        }
    }
}

ChatSession Entity

package com.vimleshpandey.demo.ai;

import jakarta.persistence.*;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;

import java.time.Instant;

@Entity
@Table(name = "chat_sessions")
@Getter
@Setter
@NoArgsConstructor
public class ChatSession {

    @Id
    private String sessionId;

    @Column(columnDefinition = "TEXT")
    private String messages = "[]";

    @Column(nullable = false, updatable = false)
    private Instant createdAt = Instant.now();

    private Instant updatedAt = Instant.now();

    public ChatSession(String sessionId) {
        this.sessionId = sessionId;
    }

    @PreUpdate
    void onUpdate() { this.updatedAt = Instant.now(); }
}
package com.vimleshpandey.demo.ai;

import org.springframework.data.jpa.repository.JpaRepository;

public interface ChatSessionRepository extends JpaRepository<ChatSession, String> {}

Wire the custom store in configuration

package com.vimleshpandey.demo.config;

import com.vimleshpandey.demo.ai.JpaChatMemoryStore;
import dev.langchain4j.memory.chat.ChatMemoryProvider;
import dev.langchain4j.memory.chat.MessageWindowChatMemory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class AiConfig {

    @Bean
    public ChatMemoryProvider chatMemoryProvider(
            JpaChatMemoryStore store,
            @Value("${langchain4j.chat-memory.max-messages:20}") int maxMessages) {
        return memoryId -> MessageWindowChatMemory.builder()
                .id(memoryId)
                .maxMessages(maxMessages)
                .chatMemoryStore(store)
                .build();
    }
}

Adding Tool Calling

Tool calling (also called function calling) lets the LLM invoke real Java methods to fetch live data. LangChain4j makes this declarative with @Tool.

OrderTrackingTools.java

package com.vimleshpandey.demo.tools;

import dev.langchain4j.agent.tool.Tool;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;

import java.util.Map;
import java.util.Optional;

@Component
@Slf4j
public class OrderTrackingTools {

    // In production this would query your order-management system
    private static final Map<String, OrderStatus> ORDERS = Map.of(
            "ORD-1001", new OrderStatus("ORD-1001", "Shipped",  "2026-09-04", "DHL", "JD014600157122"),
            "ORD-1002", new OrderStatus("ORD-1002", "Delivered", "2026-09-02", "FedEx", "779234567890"),
            "ORD-1003", new OrderStatus("ORD-1003", "Processing", null, null, null)
    );

    @Tool("Look up the current shipping status of an order given its order ID (e.g. ORD-1001).")
    public String getOrderStatus(String orderId) {
        log.info("Tool called: getOrderStatus({})", orderId);
        return Optional.ofNullable(ORDERS.get(orderId.toUpperCase()))
                .map(o -> """
                        Order ID: %s
                        Status: %s
                        Estimated delivery: %s
                        Carrier: %s
                        Tracking number: %s
                        """.formatted(
                        o.orderId(),
                        o.status(),
                        o.estimatedDelivery() != null ? o.estimatedDelivery() : "Not yet dispatched",
                        o.carrier() != null ? o.carrier() : "N/A",
                        o.trackingNumber() != null ? o.trackingNumber() : "N/A"))
                .orElse("Order %s not found. Please double-check the order ID.".formatted(orderId));
    }

    @Tool("List all products available in a given category.")
    public String listProductsByCategory(String category) {
        log.info("Tool called: listProductsByCategory({})", category);
        return switch (category.toLowerCase()) {
            case "electronics" -> "MacBook Pro M4 (€2499), Sony WH-1000XM6 headphones (€349), iPad Air 13\" (€899)";
            case "clothing"    -> "Classic Oxford Shirt (€59), Slim Fit Chinos (€79), Merino Wool Sweater (€99)";
            default -> "No products found for category: " + category;
        };
    }

    record OrderStatus(String orderId, String status, String estimatedDelivery,
                       String carrier, String trackingNumber) {}
}

Tools are automatically discovered by the LangChain4j Spring Boot starter — any @Component containing @Tool methods is wired into all @AiService beans automatically.


Building the REST API

ChatRequest / ChatResponse DTOs

package com.vimleshpandey.demo.api;

public record ChatRequest(String sessionId, String message) {}
package com.vimleshpandey.demo.api;

public record ChatResponse(String sessionId, String reply) {}

ChatController.java

package com.vimleshpandey.demo.api;

import com.vimleshpandey.demo.ai.CustomerSupportAssistant;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.UUID;

@RestController
@RequestMapping("/api/chat")
@RequiredArgsConstructor
@CrossOrigin  // allow the bundled chat UI
public class ChatController {

    private final CustomerSupportAssistant assistant;

    @PostMapping
    public ResponseEntity<ChatResponse> chat(@RequestBody ChatRequest request) {
        String sessionId = (request.sessionId() != null && !request.sessionId().isBlank())
                ? request.sessionId()
                : UUID.randomUUID().toString();

        String reply = assistant.chat(sessionId, request.message());
        return ResponseEntity.ok(new ChatResponse(sessionId, reply));
    }

    @DeleteMapping("/{sessionId}")
    public ResponseEntity<Void> clearSession(@PathVariable String sessionId) {
        // Clearing memory is handled by the ChatMemoryStore
        assistant.chat(sessionId, "\u0000");  // triggers memory init if absent
        return ResponseEntity.noContent().build();
    }
}

Adding a Chat UI

Place this in src/main/resources/static/index.html for a zero-dependency chat interface:

<!-- Chat UI (index.html) — full source included in the downloadable project ZIP.
     Key elements: a chat-box div, messages container, input row, and a small
     vanilla-JS snippet that POSTs to /api/chat, threads sessionId, and appends
     bot/user message bubbles. No external libraries required. -->

Testing

LangChain4j ships a test module that provides an AiServiceMock, letting you test your service layer without making real API calls.

ChatControllerTest.java

package com.vimleshpandey.demo;

import com.vimleshpandey.demo.ai.CustomerSupportAssistant;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;

import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

@SpringBootTest
@AutoConfigureMockMvc
class ChatControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @MockBean
    private CustomerSupportAssistant assistant;

    @Test
    void chatReturnsReply() throws Exception {
        when(assistant.chat(anyString(), anyString()))
                .thenReturn("Your order ORD-1001 has been shipped via DHL.");

        mockMvc.perform(post("/api/chat")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("""
                            { "message": "Where is my order ORD-1001?" }
                            """))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.reply").value("Your order ORD-1001 has been shipped via DHL."))
                .andExpect(jsonPath("$.sessionId").isNotEmpty());
    }

    @Test
    void chatPreservesProvidedSessionId() throws Exception {
        when(assistant.chat(anyString(), anyString())).thenReturn("Hello again!");

        mockMvc.perform(post("/api/chat")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("""
                            { "sessionId": "test-session-123", "message": "Hi" }
                            """))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.sessionId").value("test-session-123"));
    }
}

OrderTrackingToolsTest.java

package com.vimleshpandey.demo;

import com.vimleshpandey.demo.tools.OrderTrackingTools;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

import static org.assertj.core.api.Assertions.assertThat;

@SpringBootTest
class OrderTrackingToolsTest {

    @Autowired
    private OrderTrackingTools tools;

    @Test
    void knownOrderReturnsStatus() {
        String result = tools.getOrderStatus("ORD-1001");
        assertThat(result).contains("Shipped").contains("DHL");
    }

    @Test
    void unknownOrderReturnsNotFound() {
        String result = tools.getOrderStatus("ORD-9999");
        assertThat(result).containsIgnoringCase("not found");
    }

    @Test
    void productListReturnsResults() {
        String result = tools.listProductsByCategory("electronics");
        assertThat(result).contains("MacBook");
    }
}

Running the Application

1. Set your API key

export ANTHROPIC_API_KEY=your-api-key-here

2. Start the application

mvn spring-boot:run

3. Test with curl

# Start a conversation
curl -s -X POST http://localhost:8080/api/chat \
  -H 'Content-Type: application/json' \
  -d '{"message": "Hi! Where is my order ORD-1001?"}' | jq .

# Continue the same conversation (use the sessionId from above)
curl -s -X POST http://localhost:8080/api/chat \
  -H 'Content-Type: application/json' \
  -d '{"sessionId": "<sessionId>", "message": "What about ORD-1002?"}' | jq .

Or open http://localhost:8080 in your browser and use the chat UI.


Common Pitfalls and Tips

1. Tool discovery requires @Component

The LangChain4j starter scans for Spring-managed beans annotated with @Tool. If your tool methods are in a POJO (not a bean), they will be silently ignored.

2. Memory and streaming do not mix without extra config

If you switch to streaming responses (Flux return type), memory serialization must be thread-safe. Use a ConcurrentHashMap-backed store or lock at the session level.

3. Short-lived access tokens vs long conversation history

LLM context windows are large but not infinite. The MessageWindowChatMemory with maxMessages=20 keeps the last 20 turns. For production, supplement with a summary-based approach: periodically ask the model to condense older turns into a single system message.

4. Temperature and determinism in tests

Set temperature: 0.0 in your test application.yml to get deterministic responses when using a real model in integration tests.

5. Keep system prompts version-controlled

Your system prompt defines the model's personality and constraints. Treat it like source code — put it in a .txt resource file and inject it via @Value so it can be reviewed and audited separately from Java code.

6. Rate limits and retries

LangChain4j's HTTP clients do not retry by default. Add a Resilience4j @Retry around your service method or configure a custom RetryPolicy on the model builder for production robustness.


Conclusion

In this tutorial we built a production-quality AI chatbot entirely in Java using LangChain4j 1.0.0 and Spring Boot 3.5. The key takeaways:

  • @AiService turns an interface into a fully wired LLM client in seconds
  • @MemoryId provides per-user conversation isolation with zero boilerplate
  • @Tool lets the model call real Java methods, grounding its responses in live data
  • A ChatMemoryStore backed by JPA makes memory persistent across restarts
  • The entire stack is testable without real API calls using @MockBean

LangChain4j now supports over 20 LLM providers, multiple vector stores, RAG pipelines, and MCP-compatible tool calling. The patterns here scale naturally to more complex agents: add more @Tool-annotated beans, swap the Anthropic provider for OpenAI or a local Ollama instance, or layer in a ContentRetriever for RAG — all without changing your @AiService interface.

The complete project source is included below. Happy building!