skip to content

What is the difference between ChatModel and ChatClient in Spring AI, and how does ChatModel provide provider portability?

level: middleimportance: must knowfreq 65%

answer

  1. ChatModel = portable interface (Strategy)
  2. ChatClient = fluent facade on top
  3. starter on classpath -> auto-config bean
  4. swap dependency, not Java code
  5. multiple starters -> @Qualifier

basics

~20 s

ChatModel is the low-level portable interface every provider (OpenAI, Anthropic, Ollama, Azure) implements. ChatClient is the fluent, higher-level API built on top of a ChatModel to make calls ergonomic. Your code targets both, so swapping providers just means swapping the starter dependency.

solid answer

~40 s

ChatModel is the portable abstraction: an interface with a call(Prompt) → ChatResponse method that each provider implements (OpenAiChatModel, AnthropicChatModel, OllamaChatModel, AzureOpenAiChatModel). Spring Boot auto-configures the right ChatModel bean based on which spring-ai-*-starter is on the classpath. ChatClient is a fluent convenience API layered on ChatModel that handles message assembly, options, advisors, and result extraction — it is the recommended entry point for application code. Portability comes from programming against ChatModel/ChatClient rather than a vendor SDK: to switch from OpenAI to Anthropic you change the dependency and properties, not your Java code. Caveats: provider-specific options (e.g. Anthropic thinking, OpenAI response_format) live in provider ChatOptions subclasses, so leaning on those reduces portability. If multiple provider starters are present you get multiple ChatModel beans and must disambiguate with @Qualifier.

code

java · 18 lines
java
// Business code depends only on the abstraction — provider-agnostic.
@Service
class SummaryService {
    private final ChatClient chatClient;
    SummaryService(ChatModel chatModel) {          // OpenAiChatModel OR AnthropicChatModel, injected by auto-config
        this.chatClient = ChatClient.create(chatModel);
    }
    String summarize(String text) {
        return chatClient.prompt().user("Summarize: " + text).call().content();
    }
}

// Multi-provider: two beans present, disambiguate explicitly.
@Configuration
class Clients {
    @Bean ChatClient fast(OllamaChatModel m)     { return ChatClient.create(m); }
    @Bean ChatClient smart(AnthropicChatModel m) { return ChatClient.create(m); }
}

go deeper

for a junior

Know ChatClient is the friendly API and ChatModel is the thing underneath that talks to the provider.

for a middle

Explain the Strategy/abstraction split, auto-configuration by starter, and that swapping providers is a dependency+config change.

for a senior

Discuss portability limits (provider ChatOptions subclasses), multi-bean disambiguation, and choosing ChatModel vs ChatClient.

for a principal

Weigh vendor lock-in vs capability trade-offs, multi-model routing architecture, and testability of the ChatModel seam.

Spring AI deliberately separates **abstraction** from **ergonomics**: **ChatModel — the portable Strategy interface.** - It is the low-level contract: conceptually `ChatResponse call(Prompt prompt)` (plus a streaming sibling, `StreamingChatModel`, exposing a reactive `stream(Prompt)`). Most provider models implement both. - A **Prompt** is a container of Messages (system/user/assistant) plus optional ChatOptions. A **ChatResponse** wraps one or more Generations plus metadata (token usage, finish reason, model name). - Each provider ships an implementation: `OpenAiChatModel`, `AnthropicChatModel`, `OllamaChatModel`, `AzureOpenAiChatModel`, etc. This is the classic **Strategy pattern** — one interface, many interchangeable implementations. - Spring Boot **auto-configuration**: adding, say, `spring-ai-starter-model-openai` puts an `OpenAiChatModel` bean in the context, configured from `spring.ai.openai.*` properties (API key, base URL, default model, temperature). **ChatClient — the fluent facade.** - Built on top of a ChatModel via `ChatClient.create(chatModel)` or `ChatClient.builder(chatModel)`. It provides the readable chain `prompt().system().user().call()/stream()` and result extraction (`content()`, `chatResponse()`, `entity()`), plus advisors and default options set on the builder. - It is analogous to how `RestClient`/`WebClient` sit above raw HTTP: same idea, nicer API. Spring's guidance is to use **ChatClient** in app code and reach for ChatModel only when you need the raw seam (custom advisors, framework-level code, or fine control). **How portability actually works.** Because both your ChatClient and the underlying ChatModel are interfaces, your business code never imports a vendor SDK. Switching providers = swap the starter + change properties: ```properties # OpenAI spring.ai.openai.api-key=... spring.ai.openai.chat.options.model=gpt-4o # ...replace with Anthropic spring.ai.anthropic.api-key=... spring.ai.anthropic.chat.options.model=claude-sonnet-4-5 ``` No Java changes. **Gotchas / edge cases.** - **Provider-specific options break portability.** Common knobs (temperature, maxTokens, model) live in the portable `ChatOptions`; vendor-only features live in subclasses like `OpenAiChatOptions` or `AnthropicChatOptions`. Depending on those couples you to a provider. - **Multiple starters → multiple beans.** If two provider starters are on the classpath you'll have more than one ChatModel bean; injection becomes ambiguous. Use `@Qualifier`, mark one `@Primary`, or disable auto-config for the others. This is a legitimate multi-provider pattern (route different tasks to different models). - **Not every capability is portable.** Tool/function calling, structured output, and streaming are supported broadly but exact behavior/limits differ by model; test per provider. - **ChatModel is still useful directly** for building custom advisors, framework glue, or when you want to bypass the fluent layer. **When to use which:** default to ChatClient; use ChatModel for low-level or infrastructure code, and when you explicitly want multiple named models.

  • You add both the OpenAI and Ollama starters and injection of ChatModel fails to start. Why?
    Both starters auto-configure a ChatModel bean, so the context has two candidates and injection is ambiguous. Resolve with @Qualifier on the injection point, mark one @Primary, or exclude one auto-configuration.
  • Where do you set temperature or max tokens, and is it portable?
    Common options (temperature, maxTokens, model) live in the portable ChatOptions — set via defaults on the builder, spring.ai.*.chat.options.*, or per-call .options(...). Vendor-only features live in OpenAiChatOptions/AnthropicChatOptions subclasses and are not portable.

saying these in an interview costs you the question

  • Claiming ChatClient is tied to one vendor
  • Saying you must rewrite Java code to switch providers
  • Thinking ChatModel and ChatClient are the same thing
  • Assuming every provider option is portable

context