What you'll learn: how to create a Spring Boot project with a Spring AI model starter, make your first ChatClient call, control the model through configuration and per-request options, stream a response, and test your code without paying for a model call.
You already know how to add a starter, bind a few properties and inject a bean. This lesson shows that a language model fits the same pattern. By the end you will have a small service with a real chat call behind it, a unit test that never touches the network, and a clear picture of which class does what.
The core idea
Two layers matter on day one.
ChatModel is the provider-facing interface. An implementation (for OpenAI, Anthropic, Ollama and others) knows how to turn a Prompt into a provider request and the reply into a ChatResponse. You rarely call it directly in application code.
ChatClient is the fluent API you use. It builds the prompt, applies defaults and advisors, calls the ChatModel and converts the result into a String, a Java type or a full ChatResponse.
An everyday analogy: ChatModel is the JDBC driver, ChatClient is the query builder on top. Switching databases means a new driver and a connection string, not new queries. Switching model providers means a new starter and properties, not a new controller.
NOTE
Version note. The code here was checked against the Spring AI 2.0.x reference (docs.spring.io/spring-ai/reference), which states that 2.0.x supports Spring Boot 4.0.x and 4.1.x. Property names changed between lines: the 2.0 upgrade notes say the .options segment was dropped from the model properties (their worked example is spring.ai.openai.embedding.model), and the 2.0 chat pages list spring.ai.<provider>.chat.model. Some sample YAML on those pages still shows the older chat.options.model form (the Ollama page does), so trust the property table. Always open the documentation page for the version in your build.
How a call flows
Before writing code, look at what happens when your service asks a question.
sequenceDiagram
participant S as SummaryService
participant CC as ChatClient
participant AD as Advisors (if any)
participant CM as ChatModel (provider starter)
participant API as Model API
S->>CC: prompt().user(...).call().content()
Note over CC: call() picks sync mode, content() runs the request
CC->>AD: request with system text, user text, options
AD->>CM: Prompt
CM->>API: HTTPS request with API key
API-->>CM: completion and token usage
CM-->>CC: ChatResponse
CC-->>S: String
One detail from the reference documentation is worth remembering: call() only selects synchronous mode. The model is invoked when you call content(), chatResponse(), entity() or responseEntity().
Step 1: Create the project
Generate a project at start.spring.io (Spring Initializr) with Spring Web, and select one model provider under the AI section. Or add the dependencies yourself. Spring AI publishes a BOM (bill of materials) so that all its modules share one version.
Set spring-ai.version to the version the reference docs show for your release line. The starter name follows the pattern spring-ai-starter-model-<provider>. Release versions come from Maven Central, so no extra repository is needed. Snapshot and milestone builds need the Spring repositories described on the Getting Started page.
The starters for the other providers used in this course follow the same naming: spring-ai-starter-model-anthropic and spring-ai-starter-model-ollama appear in the reference pages for those providers. Use one provider per project to begin with. Having two chat starters on the classpath creates two ChatModel beans, and the auto-configured ChatClient.Builder then needs extra setup; the reference describes ChatClientBuilderConfigurer for that case.
Step 2: Configure the model
Put connection and model settings in application.yml. Read the key from the environment so that it never reaches the repository.
spring:ai:openai:api-key:${OPENAI_API_KEY}# connection property: shared by all OpenAI featureschat:model:gpt-4o-mini# explicit model name; check the provider page for valid namestemperature:0.2# lower means more repeatable answers
The same shape for the other two providers, using the property names from their reference pages:
# Anthropicspring:ai:anthropic:api-key:${ANTHROPIC_API_KEY}chat:model:<modelnamefromtheAnthropicpage>max-tokens:1024# upper bound on the reply length
# Ollama: a model running on your own machine, no API keyspring:ai:ollama:base-url:http://localhost:11434# the documented defaultchat:model:<amodelyouhavepulledwith`ollamapull`>
Set the model name explicitly. The documentation pages list defaults, and the documented default for a provider may change from one release to another, so relying on it makes the behaviour of your service depend on the framework version.
A few more properties from the provider pages are worth knowing now. The OpenAI page lists spring.ai.openai.timeout and spring.ai.openai.max-retries for the connection, and a separate spring.ai.retry prefix for the retry back-off. The Anthropic page lists spring.ai.anthropic.timeout and spring.ai.anthropic.max-retries. Lesson 7 returns to timeouts and retries; for now, know that they exist and have defaults you should read.
Step 3: Make the first call
Spring Boot auto-configures a ChatClient.Builder bean once a model starter is present. Inject the builder, set defaults, and build one client for your class. The builder is prototype-scoped, so each injection gets a fresh builder and your defaults do not leak into other classes.
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.stereotype.Service;
@ServicepublicclassSummaryService {
privatefinal ChatClient chatClient;
publicSummaryService(ChatClient.Builder builder) {
this.chatClient = builder
// The system text sets stable behaviour for every call made through this client.
.defaultSystem("You summarise customer notes for a claims team. "
+ "Be factual, use plain English and never invent details.")
.build();
}
public String summarise(String note) {
return chatClient.prompt()
// {note} is a template placeholder, filled from param() below.
.user(u -> u.text("Summarise this note in three short bullet points:\n\n{note}")
.param("note", note))
.call() // synchronous mode
.content(); // runs the request and returns the reply text
}
}
Run the application with the key in the environment and post a note with curl. You will get a few bullet points back. That is the complete loop: a Spring bean, a client, a model.
Prompts and responses
The prompt you build has parts, and the reply has more than text.
A prompt is a list of messages, each with a role. The system message sets behaviour and rules, the user message carries the request, the assistant messages are earlier model replies (relevant once you keep conversation history), and tool messages carry results of tool calls (lesson 3). defaultSystem(...) and .system(...) set the first; .user(...) sets the second.
content() returns just the text. When you need more, ask for the ChatResponse:
import org.springframework.ai.chat.model.ChatResponse;
ChatResponseresponse= chatClient.prompt()
.user("Explain optimistic locking in two sentences.")
.call()
.chatResponse();
Stringtext= response.getResult().getOutput().getText(); // the replyvarusage= response.getMetadata().getUsage(); // token counts reported by the providerIntegerinTokens= usage.getPromptTokens();
IntegeroutTokens= usage.getCompletionTokens();
Tokens are the unit that providers bill and limit. The reference notes that a token is roughly three quarters of a word, which is a useful rule of thumb for budgeting. Log getPromptTokens() and getCompletionTokens() from the first prototype so you can see what a feature costs.
For typed replies, entity(MyRecord.class) converts the answer into a Java type. That is the subject of lesson 3, so keep this one line in mind: you do not have to parse free text yourself.
Per-request options
Defaults come from properties. You can override them for one call. In the 2.0 line, options(...) takes a builder (not a built object), and a portable ChatOptions.builder() works with any provider:
import org.springframework.ai.chat.prompt.ChatOptions;
StringshortAnswer= chatClient.prompt()
.user("Name three causes of a database deadlock.")
.options(ChatOptions.builder()
.temperature(0.0) // as deterministic as the provider allows
.maxTokens(200)) // cap the reply length and cost
.call()
.content();
Use provider-specific options classes (such as OpenAiChatOptions or AnthropicChatOptions) only when you need a setting that the portable builder does not have.
Streaming at a high level
A model produces a reply token by token. Without streaming, the user waits for the whole answer; with streaming you send chunks as they arrive, which makes a chat window feel immediate.
import reactor.core.publisher.Flux;
Flux<String> chunks = chatClient.prompt()
.user("Explain eventual consistency to a new developer.")
.stream() // streaming mode instead of call()
.content(); // Flux<String> of text pieces
Two facts from the reference documentation decide how you build this. First, stream() needs the reactive stack, for example spring-boot-starter-webflux, while call() needs the servlet stack such as spring-boot-starter-web. Second, there is no built-in reactive conversion from a stream to a Java type yet, so streaming and typed output do not combine as neatly as call().entity(...). A WebFlux controller can return the Flux<String> with produces = MediaType.TEXT_EVENT_STREAM_VALUE. The details (Server-Sent Events, Spring MVC with SseEmitter, front-end handling, error cases) are in Streaming LLM responses in Spring AI.
Conversation memory in one paragraph
A model keeps no state between calls. If a user asks a follow-up question, you must send the earlier messages again. Spring AI packages that as ChatMemory plus a memory advisor:
import org.springframework.ai.chat.client.advisor.MessageChatMemoryAdvisor;
import org.springframework.ai.chat.memory.ChatMemory;
import org.springframework.ai.chat.memory.MessageWindowChatMemory;
ChatMemorychatMemory= MessageWindowChatMemory.builder()
.maxMessages(10) // keep a sliding window of recent messages
.build();
ChatClientchat= builder
.defaultAdvisors(MessageChatMemoryAdvisor.builder(chatMemory).build())
.build();
Stringreply= chat.prompt()
.user("And what about renters insurance?")
// The conversation id is required with memory advisors; there is no default.
.advisors(a -> a.param(ChatMemory.CONVERSATION_ID, conversationId))
.call()
.content();
The documentation states that the conversation id is mandatory (omitting it throws IllegalArgumentException) and recommends deriving it on the server, not trusting a value from the browser. Storage defaults to an in-memory repository; the reference lists JDBC, Cassandra, Neo4j, MongoDB and Redis repositories for durable storage. Note that the JDBC repository page says tool-call messages are not stored. Lesson 7 returns to memory design.
Testing without calling a paid model
Most of your service is ordinary Java and should have ordinary tests. The model call is the only nondeterministic, billed part, so isolate it.
Level 1: a stub ChatModel in a unit test.ChatModel has a single abstract method, call(Prompt); call(String), getOptions() and stream(Prompt) have default implementations (the default stream throws UnsupportedOperationException, so override it if you test streaming). That makes a stub easy and keeps the test free of mock libraries.
import java.util.List;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.chat.model.Generation;
import org.springframework.ai.chat.prompt.Prompt;
/** A fake model: records the prompt it received and returns a canned answer. */classStubChatModelimplementsChatModel {
Prompt lastPrompt;
privatefinal String answer;
StubChatModel(String answer) {
this.answer = answer;
}
@Overridepublic ChatResponse call(Prompt prompt) {
this.lastPrompt = prompt;
returnnewChatResponse(List.of(newGeneration(newAssistantMessage(answer))));
}
}
importstatic org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.Test;
classSummaryServiceTest {
@TestvoidsendsTheNoteAndReturnsTheModelText() {
StubChatModelmodel=newStubChatModel("- Water damage\n- Claim opened\n- Follow up Monday");
// ChatClient.builder(model) is fine in a unit test; it skips Spring Boot's auto-configuration.SummaryServiceservice=newSummaryService(ChatClient.builder(model));
Stringresult= service.summarise("Kitchen flooded on 3 May, claim opened on 4 May.");
assertThat(result).contains("Water damage");
// Assert on what we sent, not on what a model would say.
assertThat(model.lastPrompt.getContents()).contains("Kitchen flooded");
}
}
This tests your prompt construction, parameter binding and defaults. It cannot tell you whether a real model gives good answers; that is an evaluation problem (lesson 4 covers retrieval evaluation, lesson 7 covers answer quality).
Level 2: a local model for integration tests. An Ollama instance gives realistic behaviour with no per-call bill. The reference lists Testcontainers support in the spring-ai-spring-boot-testcontainers module, with service-connection factories for an OllamaContainer and for several vector stores. Local models are slower and weaker than hosted ones, so use them to test plumbing, not quality.
Level 3: a small, separate suite against the real provider. Keep a handful of end-to-end checks that run on demand or nightly, not on every commit, and cap their token budget. The reference documentation describes RelevancyEvaluator and FactCheckingEvaluator, which use a model as a judge and therefore need a model to run.
The anatomy of a Spring AI application
Keep the AI code in a small number of places so that it is easy to replace, test and observe.
The adapter is a plain Java class (such as SummaryService above, or a more specific ClaimsAssistant) with a method per use case. The rest of your system calls summarise(note), not chatClient.prompt().... That gives you one place for prompts, defaults, timeouts, caching and metrics, and one seam for tests.
Three use cases to try
Note summariser. Input: a long free-text note. Output: three bullets. Building blocks: defaultSystem, a user template, content(). Risk: the model adds details that are not in the note, so keep the instruction "never invent details" and review samples.
Ticket triage. Input: a support email. Output: a category and priority. Building blocks: a short prompt with the allowed categories listed, low temperature, later entity(...) for a typed result (lesson 3). Risk: categories drift, so validate the result against an enum.
Internal explainer. Input: a developer's question about the company's conventions. Output: a short answer with a streamed reply. Building blocks: stream(), memory with a server-side conversation id. Risk: the model does not know your conventions, which is the problem retrieval solves (lesson 4).
Choosing how to call
Pick the call style from the need, not the other way round.
flowchart TD
A[What does the caller need?] --> B{Reply shown live to a user?}
B -- Yes --> C[stream with a reactive stack, content or chatResponse]
B -- No --> D{Caller needs a Java type?}
D -- Yes --> E[call, then entity]
D -- No --> F{Need token usage or metadata?}
F -- Yes --> G[call, then chatResponse]
F -- No --> H[call, then content]
Auto-configured builder over ChatClient.create(model)
Picks up observability and customisers
Needs extra setup when several providers are present
Hosted model
Strong quality, no hardware
Network dependency, per-token cost, data leaves your process
Local model (Ollama)
No per-call fee, data stays local
Weaker quality, hardware needed, slower
call()
Simple, works on the servlet stack
The user waits for the full reply
stream()
Fast perceived response
Needs the reactive stack, harder typed output and error handling
In practice: a summary endpoint for an operations team
NOTE
A realistic scenario, not a case history. This is a composite example built from common production patterns. It does not describe any specific company, and every number in it is an illustrative assumption.
The situation. An operations team wants a button in their internal tool that summarises long case notes. Engineering has two days to deliver a prototype, and finance wants to know what the feature will cost before it goes further.
Starting architecture. A Spring Boot service exposes case data. The tool shows raw notes, which can run to several pages.
Starting point: a case service that returns raw notesOperations tool to Case service (GET /cases/{id}); Case service to Case databaseOperations tool: internal web UIOperationstoolinternal web UICase service: Spring BootCase serviceSpring BootCase database: PostgreSQLCase databasePostgreSQLGET /cases/{id}
What changes.
Add spring-ai-starter-model-openai (or the starter of the approved provider) and the BOM. Put the key in the secret store and bind it with ${...}.
Create SummaryService as above, with an explicit model name and max-tokens or maxTokens capped on the call.
Add a StubChatModel test that asserts the note reaches the prompt and that the default system text is applied.
Log getPromptTokens() and getCompletionTokens() with the case id (not the note text) so the team can estimate cost per summary.
Add a feature flag so the button can be switched off without a deploy.
Result. The team should see summaries appear in a few seconds, a unit test that runs in milliseconds with no network, and a first token-usage figure per request. Multiplying the average tokens by the provider's published price and the expected daily volume (assumed to be a few hundred requests) gives finance a cost estimate to challenge. Nothing else in the architecture moves: the new dependency is one outbound HTTPS call.
Architect's notes
Wrap the client. Give each use case a method on a class you own. When the model, prompt or provider changes, one class changes.
Pin the model name. A provider's default is a decision someone else makes. An explicit name in configuration is reviewable and reversible.
Treat the key as a production secret. Use environment variables or a secret manager, rotate it, and give non-production environments a separate key with a spending cap if the provider offers one.
Mind what you send. Notes may contain personal data. Decide before the prototype what may leave the process, and log identifiers instead of content. Spring AI's prompt and completion logging is off by default; the observability page warns that switching it on can expose sensitive data.
Plan for slow and down. Model calls are slower than database calls and fail differently. Configure timeouts and decide what the user sees when the call fails.
Prefer the simplest call. A one-shot call with a good system message solves many problems. Memory, retrieval and tools each add moving parts; add them when a failing example requires it.
Common mistakes
Using ChatClient.create(chatModel) inside Spring beans. The reference notes this bypasses the auto-configured builder, so observability and customisers are ignored. Fix: inject ChatClient.Builder.
Putting a key in application.yml. It ends up in Git. Fix: use ${OPENAI_API_KEY} style placeholders backed by the environment.
Copying property names from an older article. The .options segment changed in 2.0. Fix: check the provider page for your version; an unknown property is silently ignored by Spring Boot, so verify the effective model with a log line or an actuator view.
Forgetting the conversation id. Memory advisors throw without it. Fix: pass ChatMemory.CONVERSATION_ID on every call and derive it server-side.
Streaming on the servlet stack.stream() needs the reactive stack. Fix: add WebFlux, or use call() for non-interactive work.
Testing by calling the real model in every build. It is slow, costs money and fails randomly. Fix: stub the ChatModel for unit tests and keep a small, capped live suite.
Leaving output length uncapped. A long reply costs more and takes longer. Fix: set max tokens in properties or per call.
Key takeaways
A model starter plus a few properties gives you a ChatModel bean and an auto-configured ChatClient.Builder.
ChatClient is the fluent layer: prompt().system(...).user(...).call().content().
call() selects synchronous mode; the model runs when you ask for content(), chatResponse() or entity().
Set the model name, key placeholder, timeout and token limit explicitly, and keep secrets out of the repository.
stream() needs a reactive stack; use it for interactive replies and call() elsewhere.
Memory needs a conversation id that your server controls.
Isolate the model behind your own class and stub ChatModel in unit tests; keep a small, separate suite for quality against a real model.
Log token usage from day one.
Practice exercises
Starter (about 30 minutes). Create the project from this lesson with one provider starter. Read the API key from an environment variable, set an explicit model name, and implement SummaryService with a POST /summaries endpoint. Send three different notes with curl and compare the results. Then change temperature in the options and observe the difference over five repeated calls.
Intermediate (about 2 hours). Add the StubChatModel test from this lesson and extend it: assert that the system text is the first message in lastPrompt.getInstructions(), and add a second test where the stub throws a RuntimeException, then make SummaryService return a friendly fallback message in that case. Next, log prompt and completion token counts from chatResponse() with the note length, and plot or tabulate them for ten notes of different sizes.
Advanced (half a day). Build the streaming version: add WebFlux, expose GET /summaries/stream returning Flux<String> as Server-Sent Events, and write a small HTML page that renders the text as it arrives. Then add MessageChatMemoryAdvisor with a conversation id derived from a signed-in user, and demonstrate that a follow-up question ("shorten that") works. Write down two ways memory could leak data between users and the check that prevents each.
Upgrade notes — property and API changes between lines.
Enroll to keep reading
Lesson 2 of 7 · Spring AI: From Beginner to Expert
Set up Spring AI in a Spring Boot app, make a first ChatClient call, configure the model, stream a reply and test it without calling a paid model.
In the rest of this lesson
Streaming at a high level
Conversation memory in one paragraph
Testing without calling a paid model
The anatomy of a Spring AI application
Three use cases to try
Choosing how to call
Enrolling is free. It unlocks every lesson in Spring AI: From Beginner to Expert, tracks your progress across devices and keeps your quiz scores. The newsletter is optional.
Real architecture decisions, LLMOps patterns that survive production, and engineering leadership advice — from 12+ years of building at enterprise scale. Free. No spam. Unsubscribe anytime.
Get new articles by email
Free. We email a confirmation link first, and you can unsubscribe from any email.