Spring AI ChatClient API: The Fluent Heart of AI Integration
Spring AI ChatClient API: The Fluent Heart of AI Integration
Introduction
If you've ever tried integrating AI models into a Java application, you know the pain. HTTP clients, API keys scattered everywhere, vendor-specific SDKs that never quite fit. What should take minutes takes days.
Spring AI's ChatClient API changes everything. It's a fluent, intuitive interface that makes calling AI models feel as natural as calling any other Spring service. In this post, we'll dive deep into the ChatClient API — the core building block that everything else in Spring AI builds upon.
What is ChatClient?
At its core, ChatClient is Spring AI's abstraction over AI model communication. Think of it as the RestTemplate or WebClient for AI — but designed specifically for the conversational nature of LLMs.
The API follows three principles:
- Fluent: Method chaining that reads like English
- Flexible: Sync or streaming, your choice
- Portable: Same code works across OpenAI, Anthropic, Groq, and more
Creating a ChatClient
The Builder Pattern
@Bean
ChatClient chatClient(ChatModel chatModel) {
return ChatClient.builder(chatModel)
.defaultSystem("You are a helpful Spring Boot expert")
.build();
}
Notice something beautiful here? No API keys. No HTTP configuration. Just the ChatModel — which Spring Boot has already autoconfigured from your application.properties.
Autoconfiguration Magic
Spring AI's autoconfiguration creates a prototype ChatClient.Builder bean. Inject it and build:
@RestController
class MyController {
private final ChatClient chatClient;
public MyController(ChatClient.Builder chatClientBuilder) {
this.chatClient = chatClientBuilder.build();
}
}
The Prompt → Call → Content Chain
Here's where ChatClient shines. The fluent API follows a natural left-to-right flow:
Basic Synchronous Call
String response = chatClient.prompt()
.user("Explain Spring Boot in one sentence")
.call()
.content();
Breaking it down:
- prompt() — Starts the conversation. Add system messages, user messages, conversation history
- call() — Executes synchronously, waits for full response
- content() — Extracts just the text content
Getting Full Response Metadata
Need more than just text? Use .chatResponse() instead:
ChatResponse response = chatClient.prompt()
.user("Explain Spring Boot")
.call()
.chatResponse();
// Access metadata
Generation generation = response.getResult();
String finishReason = generation.getMetadata().getFinishReason();
int tokenCount = generation.getMetadata().getUsage().getTotalTokens();
Sync vs Streaming
Synchronous Calls
Best for: Quick operations where you need the complete answer before continuing.
String answer = chatClient.prompt()
.user("What is dependency injection?")
.call()
.content();
Streaming Calls
Best for: Chat interfaces, long-form content, real-time updates.
Flux<String> tokens = chatClient.prompt()
.user("Write a detailed Spring Boot tutorial")
.stream()
.content();
// Subscribe and display tokens as they arrive
tokens.subscribe(System.out::println);
The beauty? Just swap call() for stream() and you get a reactive Flux of tokens. Spring AI handles all the Server-Sent Events (SSE) complexity.
Entity Mapping: AI Meets POJOs
This is where ChatClient becomes production-ready. Map AI responses directly to Java objects:
record Movie(String title, String director, int year) {}
Movie recommendation = chatClient.prompt()
.user("Recommend a classic programming movie")
.call()
.entity(Movie.class);
System.out.println(recommendation.title()); // "The Social Network"
Behind the scenes, Spring AI:
- Instructs the model to return structured JSON
- Parses the response
- Maps to your record/class
- Handles type conversion and validation
No more regex parsing. No more manual JSON mapping.
Multiple Model Configuration
Real applications often need multiple AI models. Spring AI makes this elegant with @Qualifier:
@Configuration
class ChatClientConfig {
@Bean
@Qualifier("openai")
ChatClient openAiClient(ChatModel openAiModel) {
return ChatClient.builder(openAiModel)
.defaultSystem("You are an expert coder")
.build();
}
@Bean
@Qualifier("anthropic")
ChatClient anthropicClient(ChatModel anthropicModel) {
return ChatClient.builder(anthropicModel)
.defaultSystem("You are a creative writer")
.build();
}
}
// Usage
@Service
class AIService {
@Autowired
@Qualifier("openai")
private ChatClient openAiClient;
@Autowired
@Qualifier("anthropic")
private ChatClient anthropicClient;
}
Production Tips
1. Default System Prompts
Set application-wide behavior in the builder:
return ChatClient.builder(chatModel)
.defaultSystem("You are a helpful assistant. Be concise.")
.defaultOptions(ChatOptions.builder()
.temperature(0.7)
.build())
.build();
2. Request/Response Logging
For debugging, intercept the ChatClient calls with an Advisor (covered in Lecture 4):
chatClient.prompt()
.advisors(new LoggingAdvisor())
.user("Hello")
.call();
3. Error Handling
Wrap calls in try-catch for model-specific exceptions:
try {
return chatClient.prompt()
.user(userInput)
.call()
.content();
} catch (AiException e) {
// Handle rate limits, token limits, etc.
return "Service temporarily unavailable";
}
Complete REST Controller Example
Here's a production-ready controller with multiple model configs and streaming:
@RestController
@RequestMapping("/api/ai")
public class AIController {
private final ChatClient defaultClient;
private final ChatClient streamingClient;
public AIController(
@Qualifier("openai") ChatClient defaultClient,
@Qualifier("groq") ChatClient streamingClient) {
this.defaultClient = defaultClient;
this.streamingClient = streamingClient;
}
// Sync endpoint for quick responses
@GetMapping("/ask")
public ResponseEntity<String> ask(@RequestParam String question) {
String answer = defaultClient.prompt()
.user(question)
.call()
.content();
return ResponseEntity.ok(answer);
}
// Streaming endpoint for chat-like experience
@GetMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<String> stream(@RequestParam String question) {
return streamingClient.prompt()
.user(question)
.stream()
.content();
}
// Entity mapping endpoint
@PostMapping("/analyze")
public ResponseEntity<Analysis> analyze(@RequestBody String text) {
Analysis result = defaultClient.prompt()
.user("Analyze this text: " + text)
.call()
.entity(Analysis.class);
return ResponseEntity.ok(result);
}
public record Analysis(String sentiment, List<String> keyPoints) {}
}
What's Next?
Now that you understand the ChatClient API, the next lecture covers Working with Multiple AI Models — how to configure, compare, and combine different model providers in a single application.
Key Takeaways
- ChatClient.Builder gives you clean, configurable AI model access
- prompt() → call() → content() is your bread-and-butter chain
- Use streaming for real-time UX, sync for quick operations
- Entity mapping eliminates boilerplate JSON parsing
- @Qualifier pattern enables multiple model configurations
Series: Spring AI Complete Course Next: Lecture 3 — Working with Multiple AI Models Code samples: [GitHub Repository Link]
#springai #java #springboot #ai #chatclient #llm #tutorial




