Understanding IChatClient in .NET: Build Provider-Independent AI Applications | Day 2
In Day 1 of this series, we built our first AI application with C# and .NET. We configured an AI provider, created an IChatClient, sent a prompt, and received an AI-generated response.
That was enough to make the application work.
But one important line deserves much more attention:
IChatClient chatClient =
new OpenAIClient(apiKey)
.GetChatClient(model)
.AsIChatClient();Why are we using IChatClient instead of using the provider's client directly?
That question is the focus of Day 2.
Understanding IChatClient is important because it becomes one of the main building blocks for AI applications built with Microsoft.Extensions.AI.
Today, we will learn:
What
IChatClientisWhy AI abstractions are useful
How
IChatClientseparates application code from AI providersHow prompts are represented using
ChatMessageHow responses are returned through
ChatResponseHow
ChatOptionscontrols requestsHow to organize AI code more cleanly
Where
IChatClientfits into a real .NET application
By the end, our AI integration will start looking less like a simple experiment and more like application architecture we can continue building on.
Previously in This Series
In Day 1, we created a simple console application and made our first AI request using C#.
The basic flow looked like this:
C# Application
↓
IChatClient
↓
AI Provider
↓
AI Model
↓
ResponseWe intentionally kept the application simple.
Today, instead of adding many new features, we are going to understand the abstraction sitting at the center of that flow.
What Is IChatClient?
IChatClient is an interface from the Microsoft.Extensions.AI ecosystem that provides a common abstraction for interacting with AI services that support chat-style requests.
At a simplified level, we can think of it like this:
public interface IChatClient
{
// Send messages and receive a response
}The actual interface provides functionality for sending chat messages, receiving complete responses, streaming responses, accessing underlying services, and working with related AI features.
The important idea is not the number of methods.
The important idea is abstraction.
Instead of allowing our business logic to depend directly on a specific AI provider, our application can depend on:
IChatClientThis is similar to patterns .NET developers already use.
For example:
ILoggerinstead of depending on a particular logging provider.
Or:
IConfigurationinstead of knowing where every configuration value comes from.
The same architectural idea applies to AI.
Our application talks to a common interface, while the underlying implementation communicates with the actual AI provider.
Why Do We Need an AI Abstraction?
Imagine that we build our entire application directly around one provider-specific SDK.
Our service might look conceptually like this:
public class ProductDescriptionService
{
private readonly SomeProviderClient _client;
public ProductDescriptionService(SomeProviderClient client)
{
_client = client;
}
}Now imagine we use that client in 20 services.
Later, we decide to:
switch providers
use a different model
use Azure-hosted models
add testing with a fake implementation
add caching
add telemetry
introduce middleware
route different requests to different models
Our application has become tightly coupled to the original provider.
This is exactly the kind of problem abstractions are designed to reduce.
With IChatClient, our application code can instead depend on:
private readonly IChatClient _chatClient;The service does not need to know which provider is behind it.
That gives us a much cleaner boundary.
Provider-Specific Client vs IChatClient
Consider the code from Day 1:
IChatClient chatClient =
new OpenAIClient(apiKey)
.GetChatClient(model)
.AsIChatClient();There are several things happening here.
First:
new OpenAIClient(apiKey)creates the provider-specific client.
Then:
.GetChatClient(model)gets a client configured for a particular model.
Finally:
.AsIChatClient()adapts that provider-specific client to the common IChatClient abstraction.
Conceptually:
Provider SDK
↓
Provider Chat Client
↓
AsIChatClient()
↓
IChatClient
↓
Your ApplicationYour application can now work primarily with IChatClient.
That separation becomes increasingly valuable as the application grows.
Why This Matters in Real Applications
Suppose we are building an AI assistant for an inventory management system.
Eventually it might support questions such as:
Which products are running low?Summarize today's sales.Explain why profit decreased this month.Create a short description for this product.The business services handling these requests should ideally care about the task, not about the AI provider's SDK.
For example:
public class InventoryAiService
{
private readonly IChatClient _chatClient;
public InventoryAiService(IChatClient chatClient)
{
_chatClient = chatClient;
}
}Now the service depends on the capability it needs:
Chat with an AI modelrather than a specific vendor.
This follows a familiar software-design principle:
Depend on abstractions rather than concrete implementations.
Understanding the Basic IChatClient Request
The simplest request looks like this:
var response = await chatClient.GetResponseAsync(
"Explain dependency injection in C#.");
Console.WriteLine(response);This is convenient because the extension API allows us to pass a simple string.
Conceptually, the string represents a user message.
The flow becomes:
"Explain dependency injection in C#."
↓
IChatClient
↓
AI Model
↓
ChatResponseFor quick requests, this is often enough.
But real AI applications usually need more control.
For that, we can work with ChatMessage.
Understanding ChatMessage
A conversation with an AI model is normally made up of messages.
Microsoft.Extensions.AI represents these using ChatMessage.
For example:
using Microsoft.Extensions.AI;
var messages = new List<ChatMessage>
{
new(
ChatRole.System,
"You are a helpful C# programming assistant."),
new(
ChatRole.User,
"Explain dependency injection with a simple example.")
};Then send them:
var response = await chatClient.GetResponseAsync(messages);
Console.WriteLine(response);This gives us much more control over the conversation.
Understanding Chat Roles
Messages can have different roles.
Two of the most common are:
ChatRole.Systemand:
ChatRole.UserA system message defines instructions or behavior for the model.
For example:
new ChatMessage(
ChatRole.System,
"You are a senior .NET developer. Explain concepts clearly and use C# examples.")A user message contains the actual request:
new ChatMessage(
ChatRole.User,
"What is dependency injection?")Together:
var messages = new List<ChatMessage>
{
new(
ChatRole.System,
"You are a senior .NET developer. Explain concepts clearly and use C# examples."),
new(
ChatRole.User,
"What is dependency injection?")
};The distinction matters.
Instead of repeating behavioral instructions inside every user prompt, we can establish the expected behavior separately.
A Practical Example
Suppose our application needs an AI feature that explains inventory information in simple language.
We could define:
var messages = new List<ChatMessage>
{
new(
ChatRole.System,
"""
You are an inventory management assistant.
Explain business information clearly and concisely.
Do not invent missing values.
"""),
new(
ChatRole.User,
"""
Product: Wireless Mouse
Current Stock: 4
Reorder Level: 10
Explain the stock situation.
""")
};Then:
var response =
await chatClient.GetResponseAsync(messages);
Console.WriteLine(response);The model now has both:
Behavior instructions
+
Business data
+
User requestThis pattern will become very useful later when we connect AI to application data.
Understanding ChatResponse
When we call:
await chatClient.GetResponseAsync(...)we receive a chat response.
For simple console examples, this works:
Console.WriteLine(response);But treating the result as structured response data is more useful in larger applications.
For example:
var response =
await chatClient.GetResponseAsync(messages);
Console.WriteLine(response.Text);The response abstraction can carry more than just visible text.
Depending on the provider and model, response information may include additional metadata such as usage information, finish information, message content, or provider-specific data.
That is another reason not to design the entire application around converting everything immediately into a plain string.
What Is ChatOptions?
Sometimes we need more control over an AI request.
ChatOptions represents options that can be supplied with a chat request.
For example:
var options = new ChatOptions
{
Temperature = 0.2f
};Then:
var response = await chatClient.GetResponseAsync(
messages,
options);Options can influence how a request is processed, depending on what the underlying provider and model support.
You may encounter settings related to areas such as:
temperature
maximum output tokens
tools
response formats
model selection
other provider-supported capabilities
Not every provider or model supports every option in exactly the same way.
That is important.
An abstraction creates a common programming model, but it does not mean every AI provider suddenly has identical capabilities.
Temperature in Simple Terms
Temperature generally influences how deterministic or varied a model's output can be.
For tasks where consistency matters, we may prefer a lower value.
For example:
var options = new ChatOptions
{
Temperature = 0.1f
};For more creative tasks, a higher value may sometimes be appropriate.
However, do not treat temperature as a universal "creativity slider."
Different models can interpret or support generation parameters differently.
For business applications, predictable prompts, validation, structured outputs, and application logic are often more important than constantly adjusting temperature.
Passing CancellationToken
Production .NET applications should also think about cancellation.
Many AI requests involve network calls and can take time.
Instead of:
await chatClient.GetResponseAsync(messages);we can pass a cancellation token:
await chatClient.GetResponseAsync(
messages,
cancellationToken: cancellationToken);For example, inside an ASP.NET Core endpoint or service:
public async Task<string> AskAsync(
string question,
CancellationToken cancellationToken)
{
var response = await _chatClient.GetResponseAsync(
question,
cancellationToken: cancellationToken);
return response.Text;
}If the HTTP request is cancelled, the cancellation can propagate into the AI operation.
This is a small detail in a tutorial but an important habit for production code.
Don't Scatter IChatClient Calls Everywhere
Technically, we could inject IChatClient into every controller, page, component, or business class that needs AI.
For example:
public class ProductController
{
private readonly IChatClient _chatClient;
}Then:
public class ReportController
{
private readonly IChatClient _chatClient;
}Then:
public class InventoryController
{
private readonly IChatClient _chatClient;
}This can work, but it may gradually spread prompt construction and AI-specific behavior throughout the application.
A better architecture is often to create application-specific AI services.
For example:
Controller
↓
InventoryAiService
↓
IChatClient
↓
AI ProviderNow the rest of the application works with a service designed around business use cases.
Creating Our First AI Service
Create:
Services/
IAiChatService.cs
AiChatService.csStart with the interface:
public interface IAiChatService
{
Task<string> AskAsync(
string prompt,
CancellationToken cancellationToken = default);
}Now implement it:
using Microsoft.Extensions.AI;
public class AiChatService : IAiChatService
{
private readonly IChatClient _chatClient;
public AiChatService(IChatClient chatClient)
{
_chatClient = chatClient;
}
public async Task<string> AskAsync(
string prompt,
CancellationToken cancellationToken = default)
{
var response = await _chatClient.GetResponseAsync(
prompt,
cancellationToken: cancellationToken);
return response.Text;
}
}This is a very small service.
But architecturally, something important has changed.
Our application code can now depend on:
IAiChatServicewhile our AI infrastructure depends on:
IChatClientWhy Create Another Interface?
You may reasonably ask:
IChatClientis already an interface. Why createIAiChatService?
Because they solve different problems.
IChatClient represents a general AI chat capability.
IAiChatService represents something owned by our application.
Today our interface is simple:
Task<string> AskAsync(string prompt);Later, it could evolve into business-specific operations such as:
Task<string> ExplainStockAsync(...);
Task<string> SummarizeSalesAsync(...);
Task<string> GenerateProductDescriptionAsync(...);The provider abstraction belongs to the AI integration layer.
The application service represents our application's use cases.
That distinction becomes more valuable as the project grows.
A Better Project Structure
Our tutorial project can gradually move toward this structure:
CSharpAiTutorial
│
├── Services
│ ├── IAiChatService.cs
│ └── AiChatService.cs
│
├── Models
│
├── Configuration
│
└── Program.csLater, as the application becomes larger, we can separate responsibilities further.
For example:
Application
│
├── AI
│ ├── Interfaces
│ └── Services
│
Infrastructure
│
└── AI
├── Providers
└── ConfigurationWe do not need this complexity on Day 2.
The important thing is understanding where the boundaries can go.
IChatClient and Dependency Injection
One of the main advantages of using an abstraction is that it fits naturally into dependency injection.
Instead of manually constructing the AI client everywhere:
var client = new OpenAIClient(apiKey);we want configuration to happen near the application's composition root.
Then services can simply request:
IChatClientthrough constructor injection.
Conceptually:
public class AiChatService
{
private readonly IChatClient _chatClient;
public AiChatService(IChatClient chatClient)
{
_chatClient = chatClient;
}
}The service does not need to know:
where the API key came from
how the provider client was created
which SDK initialized it
where configuration is stored
Those are infrastructure concerns.
This separation is one of the biggest reasons IChatClient becomes useful in larger applications.
Why Provider Independence Matters
Consider this service:
public class AiChatService
{
private readonly IChatClient _chatClient;
public AiChatService(IChatClient chatClient)
{
_chatClient = chatClient;
}
}Notice what is missing.
There is no:
OpenAIClientinside the service.
There is no provider-specific API key logic.
There is no provider-specific configuration code.
The service only knows:
IChatClientThis makes it easier to evolve the infrastructure without rewriting business logic.
For example, we might later change:
OpenAIto another implementation that exposes the same abstraction.
The exact configuration will still need to change, and provider capabilities may differ, but much of the consuming application code can remain unchanged.
That is what provider independence means in practice.
It is not that all providers are identical.
It means our application is less tightly coupled to them.
IChatClient Also Helps Testing
Suppose we have business logic that depends directly on an external AI API.
Running tests may require:
internet access
valid credentials
API usage
waiting for remote responses
handling nondeterministic output
That is inconvenient for ordinary unit tests.
Because our code works through abstractions, we can design tests around controlled implementations instead.
For example, our application-level interface can easily be replaced:
public class FakeAiChatService : IAiChatService
{
public Task<string> AskAsync(
string prompt,
CancellationToken cancellationToken = default)
{
return Task.FromResult(
"This is a test response.");
}
}Now a test does not need to call a real model.
As our tutorial progresses, we will explore testing AI-enabled applications more carefully.
The important lesson today is that good abstractions give us options.
Complete Day 2 Example
Here is a simplified version combining the ideas from today.
IAiChatService.cs
public interface IAiChatService
{
Task<string> AskAsync(
string prompt,
CancellationToken cancellationToken = default);
}AiChatService.cs
using Microsoft.Extensions.AI;
public class AiChatService : IAiChatService
{
private readonly IChatClient _chatClient;
public AiChatService(IChatClient chatClient)
{
_chatClient = chatClient;
}
public async Task<string> AskAsync(
string prompt,
CancellationToken cancellationToken = default)
{
var messages = new List<ChatMessage>
{
new(
ChatRole.System,
"""
You are a helpful .NET programming assistant.
Give clear, concise, and technically accurate answers.
Use C# examples when useful.
"""),
new(
ChatRole.User,
prompt)
};
var response = await _chatClient.GetResponseAsync(
messages,
cancellationToken: cancellationToken);
return response.Text;
}
}Our calling code becomes very simple:
string answer = await aiChatService.AskAsync(
"Explain the repository pattern in ASP.NET Core.");
Console.WriteLine(answer);The calling code does not care which model produced the response.
That is exactly the separation we want.
What Happens Behind the Scenes?
When this runs:
await _chatClient.GetResponseAsync(messages);we can think about the flow like this:
Application
↓
IAiChatService
↓
AiChatService
↓
IChatClient
↓
Provider Adapter
↓
Provider SDK
↓
AI Model
↓
ChatResponse
↓
ApplicationEach layer has a different responsibility.
Application
Decides what the user wants to do.
AI Service
Creates the appropriate instructions and messages.
IChatClient
Provides the common AI chat abstraction.
Provider Adapter
Connects the common abstraction to the specific provider.
Provider SDK
Handles communication with the provider.
AI Model
Processes the input and generates a response.
This mental model will make later topics much easier to understand.
Common Mistake: Mixing Business Logic with Prompts
Consider this:
public async Task<IActionResult> Product(int id)
{
var product = await _repository.GetAsync(id);
var prompt =
$"Product: {product.Name}, Stock: {product.Stock}. " +
"Explain whether stock is low.";
var response =
await _chatClient.GetResponseAsync(prompt);
return View(response);
}For a tiny demo, this may be acceptable.
But as the application grows, controllers can become responsible for:
database access
prompt construction
AI calls
validation
response formatting
business decisions
That quickly becomes difficult to maintain.
Instead:
Controller
↓
Application Service
↓
AI Service
↓
IChatClientThis keeps AI behavior easier to test and change.
Common Mistake: Treating AI as Business Logic
An AI response should not automatically become a business decision.
For example, imagine the model says:
This product should be reordered immediately.Your application should not blindly create a purchase order based only on that text.
Business rules should still be implemented and validated by application code.
For example:
bool shouldReorder =
product.Stock <= product.ReorderLevel;AI can help explain the result:
Current stock is below the configured reorder level.
Consider replenishing this product.But deterministic business rules should remain deterministic when possible.
This distinction becomes especially important when AI applications begin calling tools or modifying data.
Common Mistake: Assuming Every Provider Behaves the Same
IChatClient gives us a common abstraction.
It does not guarantee identical behavior across every provider and model.
Models can differ in:
context limits
tool support
structured output capabilities
multimodal support
reasoning behavior
generation parameters
latency
pricing
response metadata
Therefore, provider independence should not be interpreted as:
Every model is interchangeable with zero testing.A better interpretation is:
Our application has a stable integration boundary.We still need to test the models and providers we choose.
Common Mistake: Reusing Mutable Request Objects Carelessly
As AI applications become concurrent, avoid assuming every request object can safely be shared and modified across multiple operations.
For example, rather than creating one mutable ChatOptions instance and changing it for many simultaneous requests, prefer creating request-specific options when appropriate:
var options = new ChatOptions
{
Temperature = 0.2f
};
var response = await _chatClient.GetResponseAsync(
messages,
options);This keeps request configuration easier to reason about.
Concurrency becomes especially important when we move from a console application to ASP.NET Core, where many requests can execute at the same time.
Security Still Matters
Using IChatClient does not automatically make an AI application secure.
Your application still needs to consider:
prompt injection
sensitive business data
personal information
API key protection
authorization
output validation
tool permissions
request size
rate limits
logging of sensitive prompts
Never assume that because an AI SDK provides an abstraction, application-level security has been handled automatically.
For example, avoid sending unnecessary secrets:
Database passwords
API keys
Access tokens
Private customer informationOnly send information the model actually needs.
IChatClient Is More Than GetResponseAsync
Today we are mainly using:
GetResponseAsync(...)But IChatClient is designed for more than simple one-shot text requests.
The abstraction also supports capabilities that become useful as our project evolves.
For example, we will eventually work with:
Streaming responses
Structured output
Function calling
Conversation history
Middleware
Telemetry
Caching
AI toolsThe important part is that we do not need a completely different architecture every time we add one of these features.
We can continue building around the same AI abstraction.
Why Microsoft.Extensions.AI Fits Naturally into .NET
For experienced .NET developers, one of the strengths of Microsoft.Extensions.AI is that the architecture feels familiar.
We already build applications around concepts such as:
Dependency Injection
Configuration
Logging
Options
Middleware
Interfaces
Service abstractions
TelemetryAI does not need to become an isolated part of the application with completely different architectural rules.
Instead, we can integrate it into normal .NET application design.
Conceptually:
ASP.NET Core Application
│
├── Controllers / Endpoints
│
├── Application Services
│
├── Data Access
│
└── AI Services
↓
IChatClient
↓
AI ProviderThis is much easier to maintain than scattering provider-specific AI calls throughout the application.
When Should You Use the Provider SDK Directly?
Does using IChatClient mean we should never use a provider-specific SDK?
No.
Sometimes a provider exposes a feature that is:
unique to that provider
not represented by the common abstraction
newly released
highly specialized
In that case, using the provider-specific SDK may be reasonable.
The goal is not abstraction for the sake of abstraction.
The goal is to keep common AI functionality behind a stable boundary while still allowing provider-specific capabilities when the application genuinely needs them.
A practical rule is:
Use the common abstraction for common capabilities.
Use provider-specific APIs when you actually need provider-specific features.What We Built Today
We started Day 2 with this:
IChatClient chatClientNow we understand why that interface matters.
We learned that IChatClient provides a common abstraction between our .NET application and an AI provider.
We also worked with:
IChatClient
ChatMessage
ChatRole
ChatResponse
ChatOptions
CancellationTokenAnd we introduced an application-level service:
IAiChatServicewith:
AiChatServiceOur architecture is beginning to look like this:
Application
↓
IAiChatService
↓
AiChatService
↓
IChatClient
↓
AI ProviderThis gives us a strong foundation for the rest of the series.
Day 2 Checklist
Before moving to Day 3, make sure you understand these concepts:
What
IChatClientrepresentsWhy provider abstraction is useful
How
.AsIChatClient()connects a provider client to the abstractionWhat
ChatMessagerepresentsThe difference between system and user messages
What
ChatResponserepresentsWhat
ChatOptionsis used forWhy cancellation matters
Why AI calls should not be scattered throughout the application
Why provider independence does not mean every model behaves identically
If these concepts are clear, the next step will feel natural.
What's Next: Day 3
Today we focused on understanding the abstraction.
In Day 3, we will move our AI integration into an ASP.NET Core application and build a reusable AI chat service using dependency injection.
We will learn how to:
register
IChatClientwith dependency injectionconfigure the AI provider centrally
inject AI services into application classes
create a reusable chat service
expose AI functionality through an ASP.NET Core endpoint
keep API keys and provider configuration outside business logic
organize the project for future AI features
The architecture will evolve from:
Console Application
↓
IChatClientinto:
ASP.NET Core
↓
Application Service
↓
IChatClient
↓
AI ProviderThat will give us the foundation for conversation memory, streaming, structured output, function calling, RAG, and eventually AI agents.
Final Thoughts
Sending a prompt to an AI model is easy.
Designing an application that can continue growing after the first prompt is the more important challenge.
IChatClient gives .NET developers a useful boundary between application code and AI infrastructure.
Instead of building the application around a specific model provider, we can build around the capability our application needs:
Chat with an AI model.The provider becomes an implementation detail behind that capability.
That does not eliminate provider differences, security concerns, testing, or good architecture. But it gives us a cleaner place to manage them.
On Day 1, we proved that our C# application could talk to an AI model.
On Day 2, we gave that integration a proper architectural boundary.
In Day 3, we will put that boundary to work inside ASP.NET Core.
Comments 0