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
@AiServiceinterface backed by Claude claude-sonnet-5 (or any other LLM) - Stateful, per-user conversation memory with a sliding window
- A custom
@Toolthat 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
| Requirement | Version |
|---|---|
| Java | 21+ |
| Spring Boot | 3.5.x |
| LangChain4j | 1.0.0 (stable) |
| Maven | 3.9+ |
| An LLM API key | Anthropic, 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 uniquesessionIdgets 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:
@AiServiceturns an interface into a fully wired LLM client in seconds@MemoryIdprovides per-user conversation isolation with zero boilerplate@Toollets the model call real Java methods, grounding its responses in live data- A
ChatMemoryStorebacked 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!